From eaf2d7f2020eed816d5b381befe4c1244bb21569 Mon Sep 17 00:00:00 2001 From: static Date: Sat, 5 Jul 2025 16:55:09 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 1 + .gitignore | 1 + src/lib/modules/file/upload.ts | 53 ++++++++++++++++++- src/lib/server/schemas/file.ts | 4 +- src/routes/api/file/[id]/thumbnail/+server.ts | 2 +- .../api/file/[id]/thumbnail/upload/+server.ts | 6 ++- 6 files changed, 60 insertions(+), 7 deletions(-) diff --git a/.dockerignore b/.dockerignore index ed4c8e5..495d123 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,7 @@ node_modules /build /data /library +/thumbnails # OS .DS_Store diff --git a/.gitignore b/.gitignore index aac77c6..73eddae 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ node_modules /build /data /library +/thumbnails # OS .DS_Store diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.ts index 71a38fb..9583fd1 100644 --- a/src/lib/modules/file/upload.ts +++ b/src/lib/modules/file/upload.ts @@ -11,9 +11,11 @@ import { digestMessage, signMessageHmac, } from "$lib/modules/crypto"; +import { generateImageThumbnail, generateVideoThumbnail } from "$lib/modules/thumbnail"; import type { DuplicateFileScanRequest, DuplicateFileScanResponse, + FileThumbnailUploadRequest, FileUploadRequest, FileUploadResponse, } from "$lib/server/schemas"; @@ -76,6 +78,24 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => { return new Date(utcDate - offsetMs); }; +const generateThumbnail = async (file: File, fileType: string) => { + let url; + try { + if (fileType.startsWith("image/")) { + url = URL.createObjectURL(file); + return await generateImageThumbnail(url); + } else if (fileType.startsWith("video/")) { + url = URL.createObjectURL(file); + return await generateVideoThumbnail(url); + } + return null; + } finally { + if (url) { + URL.revokeObjectURL(url); + } + } +}; + const encryptFile = limitFunction( async ( status: Writable, @@ -106,6 +126,11 @@ const encryptFile = limitFunction( createdAt && (await encryptString(createdAt.getTime().toString(), dataKey)); const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey); + const thumbnail = await generateThumbnail(file, fileType); + const thumbnailEncrypted = thumbnail + ? await encryptData(await thumbnail.arrayBuffer(), dataKey) + : null; + status.update((value) => { value.status = "upload-pending"; return value; @@ -120,13 +145,14 @@ const encryptFile = limitFunction( nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, + thumbnailEncrypted, }; }, { concurrency: 4 }, ); const requestFileUpload = limitFunction( - async (status: Writable, form: FormData) => { + async (status: Writable, form: FormData, thumbnailForm: FormData | null) => { status.update((value) => { value.status = "uploading"; return value; @@ -144,6 +170,15 @@ const requestFileUpload = limitFunction( }); const { file }: FileUploadResponse = res.data; + if (thumbnailForm) { + try { + await axios.post(`/api/file/${file}/thumbnail/upload`, thumbnailForm); + } catch (e) { + // TODO + console.error(e); + } + } + status.update((value) => { value.status = "uploaded"; return value; @@ -198,6 +233,7 @@ export const uploadFile = async ( nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, + thumbnailEncrypted, } = await encryptFile(status, file, fileBuffer, masterKey); const form = new FormData(); @@ -223,7 +259,20 @@ export const uploadFile = async ( form.set("content", new Blob([fileEncrypted.ciphertext])); form.set("checksum", fileEncryptedHash); - const { fileId } = await requestFileUpload(status, form); + let thumbnailForm = null; + if (thumbnailEncrypted) { + thumbnailForm = new FormData(); + thumbnailForm.set( + "metadata", + JSON.stringify({ + dekVersion: dataKeyVersion.toISOString(), + contentIv: thumbnailEncrypted.iv, + } as FileThumbnailUploadRequest), + ); + thumbnailForm.set("content", new Blob([thumbnailEncrypted.ciphertext])); + } + + const { fileId } = await requestFileUpload(status, form, thumbnailForm); return { fileId, fileBuffer }; } catch (e) { status.update((value) => { diff --git a/src/lib/server/schemas/file.ts b/src/lib/server/schemas/file.ts index 1d7ccb5..7c38911 100644 --- a/src/lib/server/schemas/file.ts +++ b/src/lib/server/schemas/file.ts @@ -32,13 +32,13 @@ export type FileRenameRequest = z.infer; export const fileThumbnailInfoResponse = z.object({ updatedAt: z.string().datetime(), - encContentIv: z.string().base64().nonempty(), + contentIv: z.string().base64().nonempty(), }); export type FileThumbnailInfoResponse = z.infer; export const fileThumbnailUploadRequest = z.object({ dekVersion: z.string().datetime(), - encContentIv: z.string().base64().nonempty(), + contentIv: z.string().base64().nonempty(), }); export type FileThumbnailUploadRequest = z.infer; diff --git a/src/routes/api/file/[id]/thumbnail/+server.ts b/src/routes/api/file/[id]/thumbnail/+server.ts index 7bc81ca..12c9347 100644 --- a/src/routes/api/file/[id]/thumbnail/+server.ts +++ b/src/routes/api/file/[id]/thumbnail/+server.ts @@ -20,7 +20,7 @@ export const GET: RequestHandler = async ({ locals, params }) => { return json( fileThumbnailInfoResponse.parse({ updatedAt: updatedAt.toISOString(), - encContentIv, + contentIv: encContentIv, } satisfies FileThumbnailInfoResponse), ); }; diff --git a/src/routes/api/file/[id]/thumbnail/upload/+server.ts b/src/routes/api/file/[id]/thumbnail/upload/+server.ts index 52b99a8..62dfe42 100644 --- a/src/routes/api/file/[id]/thumbnail/upload/+server.ts +++ b/src/routes/api/file/[id]/thumbnail/upload/+server.ts @@ -39,7 +39,9 @@ export const POST: RequestHandler = async ({ locals, params, request }) => { if (fieldname === "metadata") { // Ignore subsequent metadata fields if (!metadata) { - metadata = fileThumbnailUploadRequest.parse(val); + const zodRes = fileThumbnailUploadRequest.safeParse(JSON.parse(val)); + if (!zodRes.success) error(400, "Invalid request body"); + metadata = zodRes.data; } } else { error(400, "Invalid request body"); @@ -57,7 +59,7 @@ export const POST: RequestHandler = async ({ locals, params, request }) => { userId, id, new Date(metadata.dekVersion), - metadata.encContentIv, + metadata.contentIv, content, ); resolve(text("Thumbnail uploaded", { headers: { "Content-Type": "text/plain" } }));