From 3a637b14b429bb433a3d709dd717eaf05de41fd8 Mon Sep 17 00:00:00 2001 From: static Date: Sun, 6 Jul 2025 00:25:50 +0900 Subject: [PATCH] =?UTF-8?q?=EB=88=84=EB=9D=BD=EB=90=9C=20=EC=8D=B8?= =?UTF-8?q?=EB=84=A4=EC=9D=BC=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/modules/file/upload.ts | 7 +- src/lib/server/db/file.ts | 2 +- src/lib/server/db/media.ts | 24 ++++++ src/lib/server/schemas/file.ts | 5 ++ src/lib/server/services/file.ts | 13 ++- .../settings/thumbnails/+page.svelte | 84 +++++++++++++++++++ .../(fullscreen)/settings/thumbnails/+page.ts | 14 ++++ .../settings/thumbnails/File.svelte | 33 ++++++++ .../settings/thumbnails/service.ts | 61 ++++++++++++++ src/routes/(main)/menu/+page.svelte | 8 ++ .../api/file/scanMissingThumbnails/+server.ts | 17 ++++ 11 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 src/routes/(fullscreen)/settings/thumbnails/+page.svelte create mode 100644 src/routes/(fullscreen)/settings/thumbnails/+page.ts create mode 100644 src/routes/(fullscreen)/settings/thumbnails/File.svelte create mode 100644 src/routes/(fullscreen)/settings/thumbnails/service.ts create mode 100644 src/routes/api/file/scanMissingThumbnails/+server.ts diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.ts index 9583fd1..ac03e47 100644 --- a/src/lib/modules/file/upload.ts +++ b/src/lib/modules/file/upload.ts @@ -89,6 +89,9 @@ const generateThumbnail = async (file: File, fileType: string) => { return await generateVideoThumbnail(url); } return null; + } catch { + // TODO: Error handling + return null; } finally { if (url) { URL.revokeObjectURL(url); @@ -254,7 +257,7 @@ export const uploadFile = async ( createdAtIv: createdAtEncrypted?.iv, lastModifiedAt: lastModifiedAtEncrypted.ciphertext, lastModifiedAtIv: lastModifiedAtEncrypted.iv, - } as FileUploadRequest), + } satisfies FileUploadRequest), ); form.set("content", new Blob([fileEncrypted.ciphertext])); form.set("checksum", fileEncryptedHash); @@ -267,7 +270,7 @@ export const uploadFile = async ( JSON.stringify({ dekVersion: dataKeyVersion.toISOString(), contentIv: thumbnailEncrypted.iv, - } as FileThumbnailUploadRequest), + } satisfies FileThumbnailUploadRequest), ); thumbnailForm.set("content", new Blob([thumbnailEncrypted.ciphertext])); } diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index 2affc7d..0a76b6d 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -345,7 +345,7 @@ export const getAllFileIdsByContentHmac = async ( .where("hmac_secret_key_version", "=", hskVersion) .where("content_hmac", "=", contentHmac) .execute(); - return files.map(({ id }) => ({ id })); + return files.map(({ id }) => id); }; export const getFile = async (userId: number, fileId: number) => { diff --git a/src/lib/server/db/media.ts b/src/lib/server/db/media.ts index fbd8976..360ed49 100644 --- a/src/lib/server/db/media.ts +++ b/src/lib/server/db/media.ts @@ -84,3 +84,27 @@ export const getFileThumbnail = async (userId: number, fileId: number) => { } satisfies FileThumbnail) : null; }; + +export const getMissingFileThumbnails = async (userId: number, limit: number = 100) => { + const files = await db + .selectFrom("file") + .select("id") + .where("user_id", "=", userId) + .where((eb) => + eb.or([eb("content_type", "like", "image/%"), eb("content_type", "like", "video/%")]), + ) + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom("thumbnail") + .select("thumbnail.id") + .whereRef("thumbnail.file_id", "=", "file.id") + .limit(1), + ), + ), + ) + .limit(limit) + .execute(); + return files.map((file) => file.id); +}; diff --git a/src/lib/server/schemas/file.ts b/src/lib/server/schemas/file.ts index 7c38911..d0687b7 100644 --- a/src/lib/server/schemas/file.ts +++ b/src/lib/server/schemas/file.ts @@ -53,6 +53,11 @@ export const duplicateFileScanResponse = z.object({ }); export type DuplicateFileScanResponse = z.infer; +export const missingThumbnailFileScanResponse = z.object({ + files: z.number().int().positive().array(), +}); +export type MissingThumbnailFileScanResponse = z.infer; + export const fileUploadRequest = z.object({ parent: directoryIdSchema, mekVersion: z.number().int().positive(), diff --git a/src/lib/server/services/file.ts b/src/lib/server/services/file.ts index 7616739..6f5af03 100644 --- a/src/lib/server/services/file.ts +++ b/src/lib/server/services/file.ts @@ -16,7 +16,11 @@ import { getAllFileCategories, type NewFile, } from "$lib/server/db/file"; -import { getFileThumbnail, updateFileThumbnail } from "$lib/server/db/media"; +import { + updateFileThumbnail, + getFileThumbnail, + getMissingFileThumbnails, +} from "$lib/server/db/media"; import type { Ciphertext } from "$lib/server/db/schema"; import env from "$lib/server/loadenv"; @@ -145,7 +149,12 @@ export const scanDuplicateFiles = async ( contentHmac: string, ) => { const fileIds = await getAllFileIdsByContentHmac(userId, hskVersion, contentHmac); - return { files: fileIds.map(({ id }) => id) }; + return { files: fileIds }; +}; + +export const scanMissingFileThumbnails = async (userId: number) => { + const fileIds = await getMissingFileThumbnails(userId); + return { files: fileIds }; }; const safeUnlink = async (path: string) => { diff --git a/src/routes/(fullscreen)/settings/thumbnails/+page.svelte b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte new file mode 100644 index 0000000..f57d542 --- /dev/null +++ b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte @@ -0,0 +1,84 @@ + + + + 썸네일 설정 + + + + + {#if fileInfos && fileInfos.length > 0} +
+
+

+ {fileInfos.length}개 파일의 썸네일이 존재하지 않아요. +

+
+
+ {#each fileInfos as fileInfo} + goto(`/file/${id}`)} + onGenerateThumbnailClick={generateThumbnail} + /> + {/each} +
+
+ + + + {:else} +
+

모든 파일의 썸네일이 존재해요.

+
+ {/if} +
diff --git a/src/routes/(fullscreen)/settings/thumbnails/+page.ts b/src/routes/(fullscreen)/settings/thumbnails/+page.ts new file mode 100644 index 0000000..3fc7cff --- /dev/null +++ b/src/routes/(fullscreen)/settings/thumbnails/+page.ts @@ -0,0 +1,14 @@ +import { error } from "@sveltejs/kit"; +import { callPostApi } from "$lib/hooks"; +import type { MissingThumbnailFileScanResponse } from "$lib/server/schemas/file"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ fetch }) => { + const res = await callPostApi("/api/file/scanMissingThumbnails", undefined, fetch); + if (!res.ok) { + error(500, "Internal server error"); + } + + const { files }: MissingThumbnailFileScanResponse = await res.json(); + return { files }; +}; diff --git a/src/routes/(fullscreen)/settings/thumbnails/File.svelte b/src/routes/(fullscreen)/settings/thumbnails/File.svelte new file mode 100644 index 0000000..d06d435 --- /dev/null +++ b/src/routes/(fullscreen)/settings/thumbnails/File.svelte @@ -0,0 +1,33 @@ + + +{#if $info} + onclick($info)} + actionButtonIcon={IconCamera} + onActionButtonClick={() => onGenerateThumbnailClick($info)} + actionButtonClass="text-gray-800" + > + + +{/if} diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.ts b/src/routes/(fullscreen)/settings/thumbnails/service.ts new file mode 100644 index 0000000..a064078 --- /dev/null +++ b/src/routes/(fullscreen)/settings/thumbnails/service.ts @@ -0,0 +1,61 @@ +import { limitFunction } from "p-limit"; +import { encryptData } from "$lib/modules/crypto"; +import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file"; +import { generateImageThumbnail, generateVideoThumbnail } from "$lib/modules/thumbnail"; +import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; + +export const requestFileDownload = async ( + fileId: number, + fileEncryptedIv: string, + dataKey: CryptoKey, +) => { + const cache = await getFileCache(fileId); + if (cache) return cache; + + const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey); + storeFileCache(fileId, fileBuffer); // Intended + return fileBuffer; +}; + +export const generateThumbnail = limitFunction( + async (fileBuffer: ArrayBuffer, fileType: string) => { + let url; + try { + if (fileType.startsWith("image/")) { + url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); + return await generateImageThumbnail(url); + } else if (fileType.startsWith("video/")) { + url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); + return await generateVideoThumbnail(url); + } + return null; + } catch { + // TODO: Error handling + return null; + } finally { + if (url) { + URL.revokeObjectURL(url); + } + } + }, + { concurrency: 4 }, +); + +export const requestThumbnailUpload = limitFunction( + async (fileId: number, thumbnail: ArrayBuffer, dataKey: CryptoKey, dataKeyVersion: Date) => { + const thumbnailEncrypted = await encryptData(thumbnail, dataKey); + const form = new FormData(); + form.set( + "metadata", + JSON.stringify({ + dekVersion: dataKeyVersion.toISOString(), + contentIv: thumbnailEncrypted.iv, + } satisfies FileThumbnailUploadRequest), + ); + form.set("content", new Blob([thumbnailEncrypted.ciphertext])); + + const res = await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form }); + return res.ok; + }, + { concurrency: 1 }, +); diff --git a/src/routes/(main)/menu/+page.svelte b/src/routes/(main)/menu/+page.svelte index 13ccb92..6a52128 100644 --- a/src/routes/(main)/menu/+page.svelte +++ b/src/routes/(main)/menu/+page.svelte @@ -4,6 +4,7 @@ import { requestLogout } from "./service"; import IconStorage from "~icons/material-symbols/storage"; + import IconImage from "~icons/material-symbols/image"; import IconPassword from "~icons/material-symbols/password"; import IconLogout from "~icons/material-symbols/logout"; @@ -33,6 +34,13 @@ > 캐시 + goto("/settings/thumbnails")} + icon={IconImage} + iconColor="text-blue-500" + > + 썸네일 +

보안

diff --git a/src/routes/api/file/scanMissingThumbnails/+server.ts b/src/routes/api/file/scanMissingThumbnails/+server.ts new file mode 100644 index 0000000..c7ceef2 --- /dev/null +++ b/src/routes/api/file/scanMissingThumbnails/+server.ts @@ -0,0 +1,17 @@ +import { json } from "@sveltejs/kit"; +import { authorize } from "$lib/server/modules/auth"; +import { + missingThumbnailFileScanResponse, + type MissingThumbnailFileScanResponse, +} from "$lib/server/schemas/file"; +import { scanMissingFileThumbnails } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ locals }) => { + const { userId } = await authorize(locals, "activeClient"); + + const { files } = await scanMissingFileThumbnails(userId); + return json( + missingThumbnailFileScanResponse.parse({ files } satisfies MissingThumbnailFileScanResponse), + ); +};