From 781642fed64d2b6814e6e0d0131495e0b1e97d1c Mon Sep 17 00:00:00 2001 From: static Date: Sun, 6 Jul 2025 05:36:05 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=EC=9D=84=20?= =?UTF-8?q?=EB=A9=94=EB=AA=A8=EB=A6=AC=EC=99=80=20OPFS=EC=97=90=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 9 ++++++ .../labels/DirectoryEntryLabel.svelte | 13 ++------- src/lib/modules/file/index.ts | 1 + src/lib/modules/file/thumbnail.ts | 29 +++++++++++++++++++ src/lib/modules/file/upload.ts | 22 +++++++------- src/lib/modules/thumbnail.ts | 6 ++++ .../settings/thumbnails/service.ts | 9 ++++-- .../[[id]]/DirectoryEntries/File.svelte | 16 ++++++---- .../[[id]]/DirectoryEntries/service.ts | 8 +++-- .../(main)/directory/[[id]]/service.svelte.ts | 6 +++- 11 files changed, 88 insertions(+), 32 deletions(-) create mode 100644 src/lib/modules/file/thumbnail.ts diff --git a/package.json b/package.json index 8d0ddba..7228980 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "globals": "^15.14.0", "heic2any": "^0.0.4", "kysely-ctl": "^0.10.1", + "lru-cache": "^11.1.0", "mime": "^4.0.6", "p-limit": "^6.2.0", "prettier": "^3.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ed4442..be3e935 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: kysely-ctl: specifier: ^0.10.1 version: 0.10.1(kysely@0.27.5) + lru-cache: + specifier: ^11.1.0 + version: 11.1.0 mime: specifier: ^4.0.6 version: 4.0.6 @@ -1414,6 +1417,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + luxon@3.5.0: resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} engines: {node: '>=12'} @@ -3369,6 +3376,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.1.0: {} + luxon@3.5.0: {} magic-string@0.30.17: diff --git a/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte b/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte index 9878e26..e38b348 100644 --- a/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte +++ b/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte @@ -10,7 +10,7 @@ name: string; subtext?: string; textClass?: ClassValue; - thumbnail?: ArrayBuffer; + thumbnail?: string; type: "directory" | "file"; } @@ -22,19 +22,12 @@ thumbnail, type, }: Props = $props(); - - let thumbnailUrl: string | undefined = $state(); - - $effect(() => { - thumbnailUrl = thumbnail && URL.createObjectURL(new Blob([thumbnail])); - return () => thumbnailUrl && URL.revokeObjectURL(thumbnailUrl); - }); {#snippet iconSnippet()}
- {#if thumbnailUrl} - {name} + {#if thumbnail} + {name} {:else if type === "directory"} {:else} diff --git a/src/lib/modules/file/index.ts b/src/lib/modules/file/index.ts index 42a5613..dc708ac 100644 --- a/src/lib/modules/file/index.ts +++ b/src/lib/modules/file/index.ts @@ -1,3 +1,4 @@ export * from "./cache"; export * from "./download"; +export * from "./thumbnail"; export * from "./upload"; diff --git a/src/lib/modules/file/thumbnail.ts b/src/lib/modules/file/thumbnail.ts new file mode 100644 index 0000000..e78786c --- /dev/null +++ b/src/lib/modules/file/thumbnail.ts @@ -0,0 +1,29 @@ +import { LRUCache } from "lru-cache"; +import { readFile, writeFile, deleteFile } from "$lib/modules/opfs"; +import { getThumbnailUrl } from "$lib/modules/thumbnail"; + +const loadedThumbnails = new LRUCache({ max: 100 }); + +export const getFileThumbnail = async (fileId: number) => { + const thumbnail = loadedThumbnails.get(fileId); + if (thumbnail) { + return thumbnail; + } + + const thumbnailBuffer = await readFile(`/thumbnails/${fileId}`); + if (!thumbnailBuffer) return null; + + const thumbnailUrl = getThumbnailUrl(thumbnailBuffer); + loadedThumbnails.set(fileId, thumbnailUrl); + return thumbnailUrl; +}; + +export const storeFileThumbnail = async (fileId: number, thumbnailBuffer: ArrayBuffer) => { + await writeFile(`/thumbnails/${fileId}`, thumbnailBuffer); + loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer)); +}; + +export const deleteFileThumbnail = async (fileId: number) => { + loadedThumbnails.delete(fileId); + await deleteFile(`/thumbnails/${fileId}`); +}; diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.ts index ac03e47..b56375f 100644 --- a/src/lib/modules/file/upload.ts +++ b/src/lib/modules/file/upload.ts @@ -130,9 +130,8 @@ const encryptFile = limitFunction( const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey); const thumbnail = await generateThumbnail(file, fileType); - const thumbnailEncrypted = thumbnail - ? await encryptData(await thumbnail.arrayBuffer(), dataKey) - : null; + const thumbnailBuffer = await thumbnail?.arrayBuffer(); + const thumbnailEncrypted = thumbnailBuffer ? await encryptData(thumbnailBuffer, dataKey) : null; status.update((value) => { value.status = "upload-pending"; @@ -148,7 +147,8 @@ const encryptFile = limitFunction( nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, - thumbnailEncrypted, + thumbnail: thumbnail && + thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted }, }; }, { concurrency: 4 }, @@ -198,7 +198,9 @@ export const uploadFile = async ( hmacSecret: HmacSecret, masterKey: MasterKey, onDuplicate: () => Promise, -): Promise<{ fileId: number; fileBuffer: ArrayBuffer } | undefined> => { +): Promise< + { fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined +> => { const status = writable({ name: file.name, parentId, @@ -236,7 +238,7 @@ export const uploadFile = async ( nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, - thumbnailEncrypted, + thumbnail, } = await encryptFile(status, file, fileBuffer, masterKey); const form = new FormData(); @@ -263,20 +265,20 @@ export const uploadFile = async ( form.set("checksum", fileEncryptedHash); let thumbnailForm = null; - if (thumbnailEncrypted) { + if (thumbnail) { thumbnailForm = new FormData(); thumbnailForm.set( "metadata", JSON.stringify({ dekVersion: dataKeyVersion.toISOString(), - contentIv: thumbnailEncrypted.iv, + contentIv: thumbnail.iv, } satisfies FileThumbnailUploadRequest), ); - thumbnailForm.set("content", new Blob([thumbnailEncrypted.ciphertext])); + thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); } const { fileId } = await requestFileUpload(status, form, thumbnailForm); - return { fileId, fileBuffer }; + return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; } catch (e) { status.update((value) => { value.status = "error"; diff --git a/src/lib/modules/thumbnail.ts b/src/lib/modules/thumbnail.ts index 30e931e..2352c65 100644 --- a/src/lib/modules/thumbnail.ts +++ b/src/lib/modules/thumbnail.ts @@ -1,3 +1,5 @@ +import { encodeToBase64 } from "$lib/modules/crypto"; + const scaleSize = (width: number, height: number, targetSize: number) => { if (width <= targetSize || height <= targetSize) { return { width, height }; @@ -74,3 +76,7 @@ export const generateVideoThumbnail = (videoUrl: string, time = 0) => { video.src = videoUrl; }); }; + +export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => { + return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`; +}; diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.ts b/src/routes/(fullscreen)/settings/thumbnails/service.ts index a064078..ad24954 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/service.ts +++ b/src/routes/(fullscreen)/settings/thumbnails/service.ts @@ -1,6 +1,6 @@ import { limitFunction } from "p-limit"; import { encryptData } from "$lib/modules/crypto"; -import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file"; +import { getFileCache, storeFileCache, downloadFile, storeFileThumbnail } from "$lib/modules/file"; import { generateImageThumbnail, generateVideoThumbnail } from "$lib/modules/thumbnail"; import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; @@ -55,7 +55,10 @@ export const requestThumbnailUpload = limitFunction( form.set("content", new Blob([thumbnailEncrypted.ciphertext])); const res = await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form }); - return res.ok; + if (!res.ok) return false; + + storeFileThumbnail(fileId, thumbnail); // Intended + return true; }, - { concurrency: 1 }, + { concurrency: 4 }, ); diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte index 22870e6..4245898 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte @@ -2,9 +2,10 @@ import type { Writable } from "svelte/store"; import { ActionEntryButton } from "$lib/components/atoms"; import { DirectoryEntryLabel } from "$lib/components/molecules"; + import { getFileThumbnail } from "$lib/modules/file"; import type { FileInfo } from "$lib/modules/filesystem"; import { formatDateTime } from "$lib/modules/util"; - import { getFileThumbnail } from "./service"; + import { requestFileThumbnailDownload } from "./service"; import type { SelectedEntry } from "../service.svelte"; import IconMoreVert from "~icons/material-symbols/more-vert"; @@ -17,7 +18,7 @@ let { info, onclick, onOpenMenuClick }: Props = $props(); - let thumbnail: ArrayBuffer | undefined = $state(); + let thumbnail: string | undefined = $state(); const openFile = () => { const { id, dataKey, dataKeyVersion, name } = $info!; @@ -35,12 +36,15 @@ $effect(() => { if ($info?.dataKey) { - getFileThumbnail($info.id, $info.dataKey) - .then((thumbnailData) => { - thumbnail = thumbnailData ?? undefined; + getFileThumbnail($info.id) + .then( + (thumbnailUrl) => thumbnailUrl || requestFileThumbnailDownload($info.id, $info.dataKey!), + ) + .then((thumbnailUrl) => { + thumbnail = thumbnailUrl ?? undefined; }) .catch(() => { - // TODO: Error handling + // TODO: Error Handling thumbnail = undefined; }); } else { diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts index a14a866..70d8887 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts @@ -1,8 +1,10 @@ import { callGetApi } from "$lib/hooks"; import { decryptData } from "$lib/modules/crypto"; +import { storeFileThumbnail } from "$lib/modules/file"; +import { getThumbnailUrl } from "$lib/modules/thumbnail"; import type { FileThumbnailInfoResponse } from "$lib/server/schemas"; -export const getFileThumbnail = async (fileId: number, dataKey: CryptoKey) => { +export const requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => { let res = await callGetApi(`/api/file/${fileId}/thumbnail`); if (!res.ok) return null; @@ -13,5 +15,7 @@ export const getFileThumbnail = async (fileId: number, dataKey: CryptoKey) => { const thumbnailEncrypted = await res.arrayBuffer(); const thumbnail = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey); - return thumbnail; + + storeFileThumbnail(fileId, thumbnail); // Intended + return getThumbnailUrl(thumbnail); }; diff --git a/src/routes/(main)/directory/[[id]]/service.svelte.ts b/src/routes/(main)/directory/[[id]]/service.svelte.ts index 3c5f689..d4a0556 100644 --- a/src/routes/(main)/directory/[[id]]/service.svelte.ts +++ b/src/routes/(main)/directory/[[id]]/service.svelte.ts @@ -2,7 +2,7 @@ import { getContext, setContext } from "svelte"; import { callGetApi, callPostApi } from "$lib/hooks"; import { storeHmacSecrets } from "$lib/indexedDB"; import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto"; -import { storeFileCache, deleteFileCache, uploadFile } from "$lib/modules/file"; +import { storeFileCache, deleteFileCache, storeFileThumbnail, uploadFile } from "$lib/modules/file"; import type { DirectoryRenameRequest, DirectoryCreateRequest, @@ -81,6 +81,10 @@ export const requestFileUpload = async ( if (!res) return false; storeFileCache(res.fileId, res.fileBuffer); // Intended + if (res.thumbnailBuffer) { + storeFileThumbnail(res.fileId, res.thumbnailBuffer); // Intended + } + return true; };