From b9e6f17b0c5c65279561f1a25ebf4359f035a775 Mon Sep 17 00:00:00 2001 From: static Date: Sun, 11 Jan 2026 00:29:59 +0900 Subject: [PATCH] =?UTF-8?q?IV=EB=A5=BC=20=EC=95=94=ED=98=B8=ED=99=94?= =?UTF-8?q?=EB=90=9C=20=ED=8C=8C=EC=9D=BC=20=EB=B0=8F=20=EC=8D=B8=EB=84=A4?= =?UTF-8?q?=EC=9D=BC=20=EC=95=9E=EC=97=90=20=ED=95=A9=EC=B3=90=EC=84=9C=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/modules/crypto/aes.ts | 8 ++- src/lib/modules/file/download.svelte.ts | 17 +++-- src/lib/modules/file/thumbnail.ts | 30 ++++----- src/lib/modules/filesystem/file.ts | 2 - src/lib/modules/filesystem/types.ts | 3 +- src/lib/server/modules/http.ts | 14 +++++ src/lib/server/services/file.ts | 63 +++++++++++++++---- src/lib/services/file.ts | 8 +-- .../(fullscreen)/file/[id]/+page.svelte | 20 +++--- .../settings/thumbnail/service.ts | 2 +- src/routes/api/file/[id]/download/+server.ts | 33 ++++++++-- .../file/[id]/thumbnail/download/+server.ts | 33 ++++++++-- src/trpc/routers/file.ts | 4 +- 13 files changed, 161 insertions(+), 76 deletions(-) create mode 100644 src/lib/server/modules/http.ts diff --git a/src/lib/modules/crypto/aes.ts b/src/lib/modules/crypto/aes.ts index 3c096ba..c911d26 100644 --- a/src/lib/modules/crypto/aes.ts +++ b/src/lib/modules/crypto/aes.ts @@ -89,11 +89,15 @@ export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => { return { ciphertext, iv: encodeToBase64(iv.buffer) }; }; -export const decryptData = async (ciphertext: BufferSource, iv: string, dataKey: CryptoKey) => { +export const decryptData = async ( + ciphertext: BufferSource, + iv: string | BufferSource, + dataKey: CryptoKey, +) => { return await window.crypto.subtle.decrypt( { name: "AES-GCM", - iv: decodeFromBase64(iv), + iv: typeof iv === "string" ? decodeFromBase64(iv) : iv, } satisfies AesGcmParams, dataKey, ciphertext, diff --git a/src/lib/modules/file/download.svelte.ts b/src/lib/modules/file/download.svelte.ts index bea8316..97f42ea 100644 --- a/src/lib/modules/file/download.svelte.ts +++ b/src/lib/modules/file/download.svelte.ts @@ -62,15 +62,14 @@ const requestFileDownload = limitFunction( ); const decryptFile = limitFunction( - async ( - state: FileDownloadState, - fileEncrypted: ArrayBuffer, - fileEncryptedIv: string, - dataKey: CryptoKey, - ) => { + async (state: FileDownloadState, fileEncrypted: ArrayBuffer, dataKey: CryptoKey) => { state.status = "decrypting"; - const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey); + const fileBuffer = await decryptData( + fileEncrypted.slice(12), + fileEncrypted.slice(0, 12), + dataKey, + ); state.status = "decrypted"; state.result = fileBuffer; @@ -79,7 +78,7 @@ const decryptFile = limitFunction( { concurrency: 4 }, ); -export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => { +export const downloadFile = async (id: number, dataKey: CryptoKey) => { downloadingFiles.push({ id, status: "download-pending", @@ -87,7 +86,7 @@ export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: const state = downloadingFiles.at(-1)!; try { - return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey); + return await decryptFile(state, await requestFileDownload(state, id), dataKey); } catch (e) { state.status = "error"; throw e; diff --git a/src/lib/modules/file/thumbnail.ts b/src/lib/modules/file/thumbnail.ts index f923153..ed40e13 100644 --- a/src/lib/modules/file/thumbnail.ts +++ b/src/lib/modules/file/thumbnail.ts @@ -5,7 +5,6 @@ import { decryptData } from "$lib/modules/crypto"; import type { SummarizedFileInfo } from "$lib/modules/filesystem"; import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs"; import { getThumbnailUrl } from "$lib/modules/thumbnail"; -import { isTRPCClientError, trpc } from "$trpc/client"; const loadedThumbnails = new LRUCache>({ max: 100 }); const loadingThumbnails = new Map>(); @@ -18,25 +17,18 @@ const fetchFromOpfs = async (fileId: number) => { }; const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => { - try { - const [thumbnailEncrypted, { contentIv: thumbnailEncryptedIv }] = await Promise.all([ - fetch(`/api/file/${fileId}/thumbnail/download`), - trpc().file.thumbnail.query({ id: fileId }), - ]); - const thumbnailBuffer = await decryptData( - await thumbnailEncrypted.arrayBuffer(), - thumbnailEncryptedIv, - dataKey, - ); + const res = await fetch(`/api/file/${fileId}/thumbnail/download`); + if (!res.ok) return null; - void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); - return getThumbnailUrl(thumbnailBuffer); - } catch (e) { - if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - return null; - } - throw e; - } + const thumbnailEncrypted = await res.arrayBuffer(); + const thumbnailBuffer = await decryptData( + thumbnailEncrypted.slice(12), + thumbnailEncrypted.slice(0, 12), + dataKey, + ); + + void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); + return getThumbnailUrl(thumbnailBuffer); }; export const getFileThumbnail = (file: SummarizedFileInfo) => { diff --git a/src/lib/modules/filesystem/file.ts b/src/lib/modules/filesystem/file.ts index 7d5feb9..daf7fd6 100644 --- a/src/lib/modules/filesystem/file.ts +++ b/src/lib/modules/filesystem/file.ts @@ -50,7 +50,6 @@ const cache = new FilesystemCache({ parentId: file.parent, dataKey: metadata.dataKey, contentType: file.contentType, - contentIv: file.contentIv, name: metadata.name, createdAt: metadata.createdAt, lastModifiedAt: metadata.lastModifiedAt, @@ -118,7 +117,6 @@ const cache = new FilesystemCache({ exists: true as const, parentId: metadataRaw.parent, contentType: metadataRaw.contentType, - contentIv: metadataRaw.contentIv, categories, ...metadata, }; diff --git a/src/lib/modules/filesystem/types.ts b/src/lib/modules/filesystem/types.ts index 9f33113..abac40c 100644 --- a/src/lib/modules/filesystem/types.ts +++ b/src/lib/modules/filesystem/types.ts @@ -31,7 +31,6 @@ export interface FileInfo { parentId: DirectoryId; dataKey?: DataKey; contentType: string; - contentIv?: string; name: string; createdAt?: Date; lastModifiedAt: Date; @@ -42,7 +41,7 @@ export type MaybeFileInfo = | (FileInfo & { exists: true }) | ({ id: number; exists: false } & AllUndefined>); -export type SummarizedFileInfo = Omit; +export type SummarizedFileInfo = Omit; export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean }; interface LocalCategoryInfo { diff --git a/src/lib/server/modules/http.ts b/src/lib/server/modules/http.ts new file mode 100644 index 0000000..4f79ec5 --- /dev/null +++ b/src/lib/server/modules/http.ts @@ -0,0 +1,14 @@ +export const parseRangeHeader = (rangeHeader: string | null) => { + if (!rangeHeader) return undefined; + + const firstRange = rangeHeader.split(",")[0]!.trim(); + const parts = firstRange.replace(/bytes=/, "").split("-"); + return { + start: parts[0] ? parseInt(parts[0], 10) : undefined, + end: parts[1] ? parseInt(parts[1], 10) : undefined, + }; +}; + +export const getContentRangeHeader = (range?: { start: number; end: number; total: number }) => { + return range && { "Content-Range": `bytes ${range.start}-${range.end}/${range.total}` }; +}; diff --git a/src/lib/server/services/file.ts b/src/lib/server/services/file.ts index 9032ffb..e45b16e 100644 --- a/src/lib/server/services/file.ts +++ b/src/lib/server/services/file.ts @@ -10,30 +10,69 @@ import { FileRepo, MediaRepo, IntegrityError } from "$lib/server/db"; import env from "$lib/server/loadenv"; import { safeUnlink } from "$lib/server/modules/filesystem"; -export const getFileStream = async (userId: number, fileId: number) => { +const createEncContentStream = async ( + path: string, + iv: Buffer, + range?: { start?: number; end?: number }, +) => { + const { size: fileSize } = await stat(path); + const ivSize = iv.byteLength; + const totalSize = fileSize + ivSize; + + const start = range?.start ?? 0; + const end = range?.end ?? totalSize - 1; + if (start > end || start < 0 || end >= totalSize) { + error(416, "Invalid range"); + } + + return { + encContentStream: Readable.toWeb( + Readable.from( + (async function* () { + if (start < ivSize) { + yield iv.subarray(start, Math.min(end + 1, ivSize)); + } + if (end >= ivSize) { + yield* createReadStream(path, { + start: Math.max(0, start - ivSize), + end: end - ivSize, + }); + } + })(), + ), + ), + range: { start, end, total: totalSize }, + }; +}; + +export const getFileStream = async ( + userId: number, + fileId: number, + range?: { start?: number; end?: number }, +) => { const file = await FileRepo.getFile(userId, fileId); if (!file) { error(404, "Invalid file id"); } - const { size } = await stat(file.path); - return { - encContentStream: Readable.toWeb(createReadStream(file.path)), - encContentSize: size, - }; + return createEncContentStream(file.path, Buffer.from(file.encContentIv, "base64"), range); }; -export const getFileThumbnailStream = async (userId: number, fileId: number) => { +export const getFileThumbnailStream = async ( + userId: number, + fileId: number, + range?: { start?: number; end?: number }, +) => { const thumbnail = await MediaRepo.getFileThumbnail(userId, fileId); if (!thumbnail) { error(404, "File or its thumbnail not found"); } - const { size } = await stat(thumbnail.path); - return { - encContentStream: Readable.toWeb(createReadStream(thumbnail.path)), - encContentSize: size, - }; + return createEncContentStream( + thumbnail.path, + Buffer.from(thumbnail.encContentIv, "base64"), + range, + ); }; export const uploadFileThumbnail = async ( diff --git a/src/lib/services/file.ts b/src/lib/services/file.ts index 05a92e1..a0e769b 100644 --- a/src/lib/services/file.ts +++ b/src/lib/services/file.ts @@ -9,15 +9,11 @@ import { import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; import { trpc } from "$trpc/client"; -export const requestFileDownload = async ( - fileId: number, - fileEncryptedIv: string, - dataKey: CryptoKey, -) => { +export const requestFileDownload = async (fileId: number, dataKey: CryptoKey) => { const cache = await getFileCache(fileId); if (cache) return cache; - const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey); + const fileBuffer = await downloadFile(fileId, dataKey); storeFileCache(fileId, fileBuffer); // Intended return fileBuffer; }; diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 0b344bc..f325c5e 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -5,7 +5,7 @@ import { page } from "$app/state"; import { FullscreenDiv } from "$lib/components/atoms"; import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules"; - import { getFileInfo, type FileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; + import { getFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; import { captureVideoThumbnail } from "$lib/modules/thumbnail"; import { getFileDownloadState } from "$lib/modules/file"; import { masterKeyStore } from "$lib/stores"; @@ -95,14 +95,12 @@ untrack(() => { if (!downloadState && !isDownloadRequested) { isDownloadRequested = true; - requestFileDownload(data.id, info!.contentIv!, info!.dataKey!.key).then( - async (buffer) => { - const blob = await updateViewer(buffer, contentType); - if (!viewerType) { - FileSaver.saveAs(blob, info!.name); - } - }, - ); + requestFileDownload(data.id, info!.dataKey!.key).then(async (buffer) => { + const blob = await updateViewer(buffer, contentType); + if (!viewerType) { + FileSaver.saveAs(blob, info!.name); + } + }); } }); } @@ -110,7 +108,9 @@ $effect(() => { if (info?.exists && downloadState?.status === "decrypted") { - untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentIv!)); + untrack( + () => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentType!), + ); } }); diff --git a/src/routes/(fullscreen)/settings/thumbnail/service.ts b/src/routes/(fullscreen)/settings/thumbnail/service.ts index 85226b0..75c64b8 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/service.ts +++ b/src/routes/(fullscreen)/settings/thumbnail/service.ts @@ -77,7 +77,7 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => { await scheduler.schedule( async () => { statuses.set(fileInfo.id, "generation-pending"); - file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey?.key!); + file = await requestFileDownload(fileInfo.id, fileInfo.dataKey?.key!); return file.byteLength; }, async () => { diff --git a/src/routes/api/file/[id]/download/+server.ts b/src/routes/api/file/[id]/download/+server.ts index 5040c73..974dd54 100644 --- a/src/routes/api/file/[id]/download/+server.ts +++ b/src/routes/api/file/[id]/download/+server.ts @@ -1,10 +1,15 @@ import { error } from "@sveltejs/kit"; import { z } from "zod"; import { authorize } from "$lib/server/modules/auth"; +import { parseRangeHeader, getContentRangeHeader } from "$lib/server/modules/http"; import { getFileStream } from "$lib/server/services/file"; import type { RequestHandler } from "./$types"; -export const GET: RequestHandler = async ({ locals, params }) => { +const downloadHandler = async ( + locals: App.Locals, + params: Record, + request: Request, +) => { const { userId } = await authorize(locals, "activeClient"); const zodRes = z @@ -15,11 +20,29 @@ export const GET: RequestHandler = async ({ locals, params }) => { if (!zodRes.success) error(400, "Invalid path parameters"); const { id } = zodRes.data; - const { encContentStream, encContentSize } = await getFileStream(userId, id); - return new Response(encContentStream as ReadableStream, { + const { encContentStream, range } = await getFileStream( + userId, + id, + parseRangeHeader(request.headers.get("Range")), + ); + return { + stream: encContentStream, headers: { + "Accept-Ranges": "bytes", + "Content-Length": (range.end - range.start + 1).toString(), "Content-Type": "application/octet-stream", - "Content-Length": encContentSize.toString(), + ...getContentRangeHeader(range), }, - }); + isRangeRequest: !!range, + }; +}; + +export const GET: RequestHandler = async ({ locals, params, request }) => { + const { stream, headers, isRangeRequest } = await downloadHandler(locals, params, request); + return new Response(stream as ReadableStream, { status: isRangeRequest ? 206 : 200, headers }); +}; + +export const HEAD: RequestHandler = async ({ locals, params, request }) => { + const { headers, isRangeRequest } = await downloadHandler(locals, params, request); + return new Response(null, { status: isRangeRequest ? 206 : 200, headers }); }; diff --git a/src/routes/api/file/[id]/thumbnail/download/+server.ts b/src/routes/api/file/[id]/thumbnail/download/+server.ts index addd800..70d4cd3 100644 --- a/src/routes/api/file/[id]/thumbnail/download/+server.ts +++ b/src/routes/api/file/[id]/thumbnail/download/+server.ts @@ -1,10 +1,15 @@ import { error } from "@sveltejs/kit"; import { z } from "zod"; import { authorize } from "$lib/server/modules/auth"; +import { parseRangeHeader, getContentRangeHeader } from "$lib/server/modules/http"; import { getFileThumbnailStream } from "$lib/server/services/file"; import type { RequestHandler } from "./$types"; -export const GET: RequestHandler = async ({ locals, params }) => { +const downloadHandler = async ( + locals: App.Locals, + params: Record, + request: Request, +) => { const { userId } = await authorize(locals, "activeClient"); const zodRes = z @@ -15,11 +20,29 @@ export const GET: RequestHandler = async ({ locals, params }) => { if (!zodRes.success) error(400, "Invalid path parameters"); const { id } = zodRes.data; - const { encContentStream, encContentSize } = await getFileThumbnailStream(userId, id); - return new Response(encContentStream as ReadableStream, { + const { encContentStream, range } = await getFileThumbnailStream( + userId, + id, + parseRangeHeader(request.headers.get("Range")), + ); + return { + stream: encContentStream, headers: { + "Accept-Ranges": "bytes", + "Content-Length": (range.end - range.start + 1).toString(), "Content-Type": "application/octet-stream", - "Content-Length": encContentSize.toString(), + ...getContentRangeHeader(range), }, - }); + isRangeRequest: !!range, + }; +}; + +export const GET: RequestHandler = async ({ locals, params, request }) => { + const { stream, headers, isRangeRequest } = await downloadHandler(locals, params, request); + return new Response(stream as ReadableStream, { status: isRangeRequest ? 206 : 200, headers }); +}; + +export const HEAD: RequestHandler = async ({ locals, params, request }) => { + const { headers, isRangeRequest } = await downloadHandler(locals, params, request); + return new Response(null, { status: isRangeRequest ? 206 : 200, headers }); }; diff --git a/src/trpc/routers/file.ts b/src/trpc/routers/file.ts index c3f8159..a56a91f 100644 --- a/src/trpc/routers/file.ts +++ b/src/trpc/routers/file.ts @@ -24,7 +24,6 @@ const fileRouter = router({ dek: file.encDek, dekVersion: file.dekVersion, contentType: file.contentType, - contentIv: file.encContentIv, name: file.encName.ciphertext, nameIv: file.encName.iv, createdAt: file.encCreatedAt?.ciphertext, @@ -58,7 +57,6 @@ const fileRouter = router({ dek: file.encDek, dekVersion: file.dekVersion, contentType: file.contentType, - contentIv: file.encContentIv, name: file.encName.ciphertext, nameIv: file.encName.iv, createdAt: file.encCreatedAt?.ciphertext, @@ -158,7 +156,7 @@ const fileRouter = router({ throw new TRPCError({ code: "NOT_FOUND", message: "File or its thumbnail not found" }); } - return { updatedAt: thumbnail.updatedAt, contentIv: thumbnail.encContentIv }; + return { updatedAt: thumbnail.updatedAt }; }), });