From 1efcdd68f1d5364fe7283cdec020d02707d5f4fc Mon Sep 17 00:00:00 2001 From: static Date: Sun, 11 Jan 2026 09:25:40 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=AC=EB=B0=8D=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=8F=99=EC=98=81?= =?UTF-8?q?=EC=83=81=EC=9D=84=20=EB=B6=88=EB=9F=AC=EC=98=AC=20=EB=95=8C=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=EA=B0=80=20=ED=91=9C=EC=8B=9C=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(fullscreen)/file/[id]/+page.svelte | 1 + .../(fullscreen)/file/[id]/TopBarMenu.svelte | 22 +++-- src/service-worker/handlers/decryptFile.ts | 96 +++++++++++++------ 3 files changed, 83 insertions(+), 36 deletions(-) diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 674bc22..053d6bf 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -154,6 +154,7 @@ ? info?.parentId : undefined} {fileBlob} + downloadUrl={videoStreamUrl} filename={info?.name} /> diff --git a/src/routes/(fullscreen)/file/[id]/TopBarMenu.svelte b/src/routes/(fullscreen)/file/[id]/TopBarMenu.svelte index a037b61..d713e8c 100644 --- a/src/routes/(fullscreen)/file/[id]/TopBarMenu.svelte +++ b/src/routes/(fullscreen)/file/[id]/TopBarMenu.svelte @@ -10,17 +10,29 @@ interface Props { directoryId?: "root" | number; + downloadUrl?: string; fileBlob?: Blob; filename?: string; isOpen: boolean; } - let { directoryId, fileBlob, filename, isOpen = $bindable() }: Props = $props(); + let { directoryId, downloadUrl, fileBlob, filename, isOpen = $bindable() }: Props = $props(); + + const handleDownload = () => { + if (fileBlob && filename) { + FileSaver.saveAs(fileBlob, filename); + } else if (downloadUrl && filename) { + // Use streaming download via Content-Disposition header + const url = new URL(downloadUrl, window.location.origin); + url.searchParams.set("download", filename); + window.open(url.toString(), "_blank"); + } + }; (isOpen = false)} /> -{#if isOpen && (directoryId || fileBlob)} +{#if isOpen && (directoryId || downloadUrl || fileBlob)}
{ - FileSaver.saveAs(fileBlob, filename); - })} + {#if fileBlob || downloadUrl} + {@render menuButton(IconCloudDownload, "다운로드", handleDownload)} {/if}
diff --git a/src/service-worker/handlers/decryptFile.ts b/src/service-worker/handlers/decryptFile.ts index e374e5d..22aa118 100644 --- a/src/service-worker/handlers/decryptFile.ts +++ b/src/service-worker/handlers/decryptFile.ts @@ -10,15 +10,23 @@ const createResponse = ( isRangeRequest: boolean, range: { start: number; end: number; total: number }, contentType?: string, + downloadFilename?: string, ) => { + const headers: Record = { + "Accept-Ranges": "bytes", + "Content-Length": String(range.end - range.start + 1), + "Content-Type": contentType ?? "application/octet-stream", + ...(isRangeRequest ? getContentRangeHeader(range) : {}), + }; + + if (downloadFilename) { + headers["Content-Disposition"] = + `attachment; filename*=UTF-8''${encodeURIComponent(downloadFilename)}`; + } + return new Response(stream, { status: isRangeRequest ? 206 : 200, - headers: { - "Accept-Ranges": "bytes", - "Content-Length": String(range.end - range.start + 1), - "Content-Type": contentType ?? "application/octet-stream", - ...(isRangeRequest ? getContentRangeHeader(range) : {}), - }, + headers, }); }; @@ -26,6 +34,7 @@ const streamFromOpfs = async ( file: File, metadata?: FileMetadata, range?: { start?: number; end?: number }, + downloadFilename?: string, ) => { const start = range?.start ?? 0; const end = range?.end ?? file.size - 1; @@ -38,6 +47,7 @@ const streamFromOpfs = async ( !!range, { start, end, total: file.size }, metadata?.contentType, + downloadFilename, ); }; @@ -45,6 +55,7 @@ const streamFromServer = async ( id: number, metadata: FileMetadata, range?: { start?: number; end?: number }, + downloadFilename?: string, ) => { const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy); const start = range?.start ?? 0; @@ -59,39 +70,63 @@ const streamFromServer = async ( const apiResponse = await fetch(`/api/file/${id}/download`, { headers: { Range: `bytes=${encryptedRange.start}-${encryptedRange.end}` }, }); - if (apiResponse.status !== 206) { + if (apiResponse.status !== 206 || !apiResponse.body) { return new Response("Failed to fetch encrypted file", { status: 502 }); } - const fileEncrypted = await apiResponse.arrayBuffer(); - return createResponse( - new ReadableStream({ - async start(controller) { - if (metadata.isLegacy) { - const decrypted = await decryptChunk(fileEncrypted, metadata.dataKey); + if (metadata.isLegacy) { + const fileEncrypted = await apiResponse.arrayBuffer(); + const decrypted = await decryptChunk(fileEncrypted, metadata.dataKey); + return createResponse( + new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(decrypted.slice(start, end + 1))); controller.close(); - return; - } + }, + }), + !!range, + { start, end, total: totalSize }, + metadata.contentType, + ); + } - const chunks = encryptedRange.lastChunkIndex - encryptedRange.firstChunkIndex + 1; + const totalChunks = encryptedRange.lastChunkIndex - encryptedRange.firstChunkIndex + 1; + let currentChunkIndex = 0; + let buffer = new Uint8Array(0); - for (let i = 0; i < chunks; i++) { - const chunk = await decryptChunk( - fileEncrypted.slice(i * ENCRYPTED_CHUNK_SIZE, (i + 1) * ENCRYPTED_CHUNK_SIZE), - metadata.dataKey, - ); - const sliceStart = i === 0 ? start % CHUNK_SIZE : 0; - const sliceEnd = i === chunks - 1 ? (end % CHUNK_SIZE) + 1 : chunk.byteLength; - controller.enqueue(new Uint8Array(chunk.slice(sliceStart, sliceEnd))); - } + const decryptingStream = new TransformStream({ + async transform(chunk, controller) { + const newBuffer = new Uint8Array(buffer.length + chunk.length); + newBuffer.set(buffer); + newBuffer.set(chunk, buffer.length); + buffer = newBuffer; - controller.close(); - }, - }), + while (buffer.length >= ENCRYPTED_CHUNK_SIZE && currentChunkIndex < totalChunks - 1) { + const encryptedChunk = buffer.slice(0, ENCRYPTED_CHUNK_SIZE); + buffer = buffer.slice(ENCRYPTED_CHUNK_SIZE); + + const decrypted = await decryptChunk(encryptedChunk.buffer, metadata.dataKey); + const sliceStart = currentChunkIndex === 0 ? start % CHUNK_SIZE : 0; + controller.enqueue(new Uint8Array(decrypted.slice(sliceStart))); + currentChunkIndex++; + } + }, + async flush(controller) { + if (buffer.length > 0) { + const decrypted = await decryptChunk(buffer.buffer, metadata.dataKey); + const sliceStart = currentChunkIndex === 0 ? start % CHUNK_SIZE : 0; + const sliceEnd = (end % CHUNK_SIZE) + 1; + controller.enqueue(new Uint8Array(decrypted.slice(sliceStart, sliceEnd))); + } + }, + }); + + return createResponse( + apiResponse.body.pipeThrough(decryptingStream), !!range, { start, end, total: totalSize }, metadata.contentType, + downloadFilename, ); }; @@ -102,13 +137,14 @@ const decryptFileHandler = async (request: Request) => { throw new Response("Invalid file id", { status: 400 }); } + const downloadFilename = url.searchParams.get("download") ?? undefined; const metadata = fileMetadataStore.get(fileId); const range = parseRangeHeader(request.headers.get("Range")); const cache = await getFile(`/cache/${fileId}`); if (cache) { - return streamFromOpfs(cache, metadata, range); + return streamFromOpfs(cache, metadata, range, downloadFilename); } else if (metadata) { - return streamFromServer(fileId, metadata, range); + return streamFromServer(fileId, metadata, range, downloadFilename); } else { return new Response("Decryption not prepared", { status: 400 }); }