Stream purchased files into durable cache and avoid duplicate concurrent payments
This commit is contained in:
@@ -75,6 +75,26 @@ describe('usePaidItemViewer — UIFIX-04 (lightbox routing) + UIFIX-06 (loading/
|
||||
vi.stubGlobal('atob', vi.fn(() => 'binarydata'))
|
||||
})
|
||||
|
||||
it('opens a large cached video through authenticated HTTP without decoding or a blob allocation', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ owned: true, mime_type: 'video/mp4', size_bytes: 200000000 })
|
||||
const viewer = usePaidItemViewer()
|
||||
await viewer.open(VIDEO_ITEM)
|
||||
expect(await viewer.resolveBlobUrl(viewer.lightboxItems.value[0]!.path)).toBe('/api/peer-content/abc123.onion/content-2')
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
expect(atob).not.toHaveBeenCalled()
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith(expect.objectContaining({ params: { onion: VIDEO_ITEM.onion, content_id: VIDEO_ITEM.content_id, cache_only: true } }))
|
||||
})
|
||||
|
||||
it('does not open incomplete delivery or start another purchase', async () => {
|
||||
mockedRpc.call.mockRejectedValue(new Error('Delivery incomplete'))
|
||||
const viewer = usePaidItemViewer()
|
||||
await viewer.open(VIDEO_ITEM)
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
expect(viewer.error.value).toBeTruthy()
|
||||
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes an image mime to the lightbox, not window.open', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
@@ -22,7 +22,7 @@ export function paidItemKey(it: OwnedItemLike): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch, decode, route-to-viewer and loading/error state for a purchased
|
||||
* Validate ownership, stream to viewer and manage loading/error state for a purchased
|
||||
* item (UIFIX-04 / UIFIX-06).
|
||||
*
|
||||
* - image/video → routed into the caller's MediaLightbox via `lightboxItems`
|
||||
@@ -42,7 +42,7 @@ export function usePaidItemViewer() {
|
||||
const lightboxItems = ref<FileBrowserItem[]>([])
|
||||
const lightboxIndex = ref<number | null>(null)
|
||||
|
||||
// Synthetic-path -> already-fetched blob URL. Populated only for items
|
||||
// Synthetic-path -> authenticated cache URL (or legacy blob URL). Populated only for items
|
||||
// routed to the lightbox; resolveBlobUrl reads from here and never fetches.
|
||||
const urlByPath = new Map<string, string>()
|
||||
// One in-flight fetch per item key — a second open() for the same key
|
||||
@@ -61,21 +61,20 @@ export function usePaidItemViewer() {
|
||||
opening.value = key
|
||||
error.value = null
|
||||
try {
|
||||
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({
|
||||
const res = await rpcClient.call<{ data_base64?: string; data?: string; owned?: boolean; mime_type?: string; error?: string }>({
|
||||
method: 'content.owned-get',
|
||||
params: { onion: it.onion, content_id: it.content_id },
|
||||
params: { onion: it.onion, content_id: it.content_id, cache_only: true },
|
||||
timeout: 60000,
|
||||
})
|
||||
const b64 = res.data_base64 || res.data
|
||||
if (!b64) {
|
||||
error.value = "Couldn't open this file — the peer returned no data."
|
||||
const b64 = res.data_base64 ?? res.data
|
||||
if (b64 === undefined && res.owned !== true) {
|
||||
error.value = res.error || "Couldn't open this file — its cached copy is unavailable."
|
||||
return
|
||||
}
|
||||
const bin = atob(b64)
|
||||
const arr = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
|
||||
const mime = res.mime_type || it.mime_type
|
||||
const url = URL.createObjectURL(new Blob([arr], { type: mime }))
|
||||
const url = b64 === undefined
|
||||
? `/api/peer-content/${encodeURIComponent(it.onion)}/${encodeURIComponent(it.content_id)}`
|
||||
: URL.createObjectURL(new Blob([Uint8Array.from(atob(b64), c => c.charCodeAt(0))], { type: mime }))
|
||||
|
||||
// Music ALWAYS plays in the global bottom-bar player — never a
|
||||
// lightbox (blob URL stays alive for the bar; it owns playback now).
|
||||
@@ -104,7 +103,7 @@ export function usePaidItemViewer() {
|
||||
|
||||
// No in-app viewer (documents, etc.) — keep today's behaviour.
|
||||
window.open(url, '_blank', 'noopener')
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||
if (url.startsWith("blob:")) setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||
} catch {
|
||||
error.value = "Couldn't open this file — it may be unavailable right now."
|
||||
} finally {
|
||||
|
||||
@@ -701,7 +701,7 @@ function base64ToBlob(base64: string, mime: string): Blob {
|
||||
}
|
||||
|
||||
// In-app viewer (lightbox) for owned content AND free images. Owned content is
|
||||
// loaded as a blob URL from the purchase cache; free images point straight at
|
||||
// loaded through local cached streaming; free images point straight at
|
||||
// the Range-capable streaming proxy (no base64 round-trip).
|
||||
const viewerItem = ref<CatalogItem | null>(null)
|
||||
const viewerUrl = ref<string | null>(null)
|
||||
@@ -717,22 +717,23 @@ async function viewOwned(item: CatalogItem) {
|
||||
playing.value = item.id
|
||||
purchaseError.value = null
|
||||
try {
|
||||
const res = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
|
||||
const res = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
||||
method: 'content.owned-get',
|
||||
params: { onion, content_id: item.id },
|
||||
params: { onion, content_id: item.id, filename: item.filename, cache_only: true },
|
||||
timeout: 60000,
|
||||
})
|
||||
if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return }
|
||||
if (res?.data === undefined && res?.owned !== true) { purchaseError.value = res?.error || 'Could not open your purchased file'; return }
|
||||
const mime = res.mime_type || item.mime_type
|
||||
const url = res.data !== undefined ? URL.createObjectURL(base64ToBlob(res.data, mime))
|
||||
: `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(res.owned_content_id || item.id)}`
|
||||
// Audio always plays in the global bottom-bar player — never the lightbox
|
||||
// (the blob URL is intentionally not revoked while the bar plays it).
|
||||
if (mime.startsWith('audio/')) {
|
||||
const url = URL.createObjectURL(base64ToBlob(res.data, mime))
|
||||
audioPlayer.play(url, item.filename.split('/').pop() || item.filename)
|
||||
return
|
||||
}
|
||||
releaseViewerUrl()
|
||||
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
|
||||
viewerUrl.value = url
|
||||
viewerMime.value = mime
|
||||
viewerItem.value = item
|
||||
} catch (e: unknown) {
|
||||
@@ -797,12 +798,13 @@ async function saveOwned(item: CatalogItem) {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!onion) return
|
||||
try {
|
||||
const res = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
|
||||
const res = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
||||
method: 'content.owned-get',
|
||||
params: { onion, content_id: item.id },
|
||||
params: { onion, content_id: item.id, filename: item.filename, cache_only: true },
|
||||
timeout: 60000,
|
||||
})
|
||||
if (res?.data) triggerDownload(res.data, item)
|
||||
if (res?.owned === true) streamDownload(`/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(res.owned_content_id || item.id)}`, item)
|
||||
else if (res?.data) triggerDownload(res.data, item)
|
||||
else purchaseError.value = res?.error || 'Could not save your purchased file'
|
||||
} catch (e: unknown) {
|
||||
purchaseError.value = e instanceof Error ? e.message : 'Could not save your purchased file'
|
||||
@@ -1362,11 +1364,11 @@ async function prepareEcashPay() {
|
||||
* mobile companion ("paid but never unlocked"); the viewer's Save button
|
||||
* still offers an explicit download.
|
||||
*/
|
||||
function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeType?: string, seller?: string) {
|
||||
function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeType?: string, seller?: string, cachedId?: string) {
|
||||
const onion = seller || props.peerId || currentPeer.value?.onion
|
||||
const url = base64Data !== undefined
|
||||
? URL.createObjectURL(base64ToBlob(base64Data, mimeType || item.mime_type))
|
||||
: `/api/peer-content/${encodeURIComponent(onion || "")}/${encodeURIComponent(item.id)}`
|
||||
: `/api/peer-content/${encodeURIComponent(onion || "")}/${encodeURIComponent(cachedId || item.id)}`
|
||||
if (onion) {
|
||||
try { localStorage.removeItem(receiptKey(onion, item.id)) } catch { /* owned cache remains authoritative */ }
|
||||
lnReceipt.value = null
|
||||
@@ -1395,20 +1397,20 @@ async function confirmEcashPay() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
const method = ecashPlan.value?.chosen
|
||||
if (!item || !onion || !method) return
|
||||
if (!item || !onion || !method || downloading.value) return
|
||||
const price = getItemPrice(item.access)
|
||||
|
||||
downloading.value = item.id
|
||||
purchaseError.value = null
|
||||
try {
|
||||
const result = await rpcClient.call<{ data?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
|
||||
const result = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
|
||||
method: 'content.download-peer-paid',
|
||||
params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename },
|
||||
timeout: 120000,
|
||||
params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename, cache_only: true },
|
||||
timeout: 960000,
|
||||
})
|
||||
if (result?.data) {
|
||||
if (result?.data !== undefined || result?.owned === true) {
|
||||
// The purchase is now cached + owned by this node (backend persisted it).
|
||||
openPurchased(item, result.data, result.mime_type)
|
||||
openPurchased(item, result.data, result.mime_type, onion, result.owned_content_id)
|
||||
} else if (result?.error) {
|
||||
// Keep the confirm screen open so the user can switch backend and retry.
|
||||
purchaseError.value = result.error
|
||||
@@ -1486,7 +1488,7 @@ async function payWithLightning() {
|
||||
}
|
||||
}
|
||||
lnReceipt.value = inv
|
||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
|
||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
||||
method: 'content.download-peer-invoice',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
||||
timeout: 960000,
|
||||
@@ -1517,7 +1519,7 @@ async function pollInvoice() {
|
||||
if (res?.paid) {
|
||||
// Settled — pull the file using the payment hash as the gate token.
|
||||
invoiceWaiting.value = false
|
||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
|
||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
||||
method: 'content.download-peer-invoice',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
||||
timeout: 960000,
|
||||
|
||||
@@ -30,6 +30,35 @@ beforeEach(() => {
|
||||
vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never)
|
||||
})
|
||||
describe('Lightning file delivery recovery', () => {
|
||||
it('opens a cached ecash purchase without transferring base64 into the UI', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'content.download-peer-paid') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
|
||||
return { items: [] }
|
||||
})
|
||||
const { wrapper, vm } = await open()
|
||||
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
|
||||
await vm.confirmEcashPay()
|
||||
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
|
||||
expect(vm.viewerMime).toBe('video/mp4')
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-paid')![0].params).toMatchObject({ cache_only: true, method: 'cashu' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not issue a duplicate ecash purchase while delivery is pending', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'content.download-peer-paid') return await new Promise(resolve => { finish = resolve })
|
||||
return { items: [] }
|
||||
})
|
||||
const { wrapper, vm } = await open()
|
||||
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
|
||||
const first = vm.confirmEcashPay()
|
||||
await vm.confirmEcashPay()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.download-peer-paid')).toHaveLength(1)
|
||||
finish({ error: 'Delivery needs recovery; do not pay again' })
|
||||
await first
|
||||
expect(vm.purchaseError).toContain('do not pay again')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retries delivery after a seller rejection without paying or requesting another invoice', async () => {
|
||||
download.mockResolvedValue({ error: 'Seller has not registered this payment yet' })
|
||||
const { wrapper, vm } = await open()
|
||||
|
||||
@@ -612,26 +612,29 @@ async function purchaseAndDownload(item: PeerContentItem) {
|
||||
|
||||
purchasingId.value = item.id
|
||||
try {
|
||||
const result = await rpcClient.call<{ data?: string; error?: string }>({
|
||||
const result = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; error?: string }>({
|
||||
method: 'content.download-peer-paid',
|
||||
params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price },
|
||||
timeout: 120000,
|
||||
params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price, filename: item.filename, cache_only: true },
|
||||
timeout: 960000,
|
||||
})
|
||||
|
||||
if (result?.data) {
|
||||
const blob = new Blob(
|
||||
[Uint8Array.from(atob(result.data), c => c.charCodeAt(0))],
|
||||
{ type: item.mime_type },
|
||||
)
|
||||
if (result?.owned === true) {
|
||||
const a = document.createElement('a')
|
||||
a.href = `/api/peer-content/${encodeURIComponent(browsePeerOnion.value)}/${encodeURIComponent(result.owned_content_id || item.id)}`
|
||||
a.download = item.filename.split('/').pop() || item.filename
|
||||
a.click()
|
||||
emit('toast', 'Purchase saved — download started')
|
||||
} else if (result?.data) {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], { type: item.mime_type })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = item.filename.split('/').pop() || item.filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
emit('toast', `Downloaded for ${price} sats`)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||
emit('toast', 'Purchase saved — download started')
|
||||
} else {
|
||||
emit('toast', 'Purchase failed — no data received')
|
||||
emit('toast', result?.error || 'Purchase could not be completed')
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
emit('toast', e instanceof Error ? e.message : 'Purchase failed')
|
||||
|
||||
Reference in New Issue
Block a user