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/.env.example b/.env.example index f492443..e3b6365 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,4 @@ SESSION_EXPIRES= USER_CLIENT_CHALLENGE_EXPIRES= SESSION_UPGRADE_CHALLENGE_EXPIRES= LIBRARY_PATH= +THUMBNAILS_PATH= 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/docker-compose.yaml b/docker-compose.yaml index dc7f392..eba1e94 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,6 +7,7 @@ services: user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0} volumes: - ./data/library:/app/data/library + - ./data/thumbnails:/app/data/thumbnails environment: # ArkVault - DATABASE_HOST=database @@ -17,6 +18,7 @@ services: - USER_CLIENT_CHALLENGE_EXPIRES - SESSION_UPGRADE_CHALLENGE_EXPIRES - LIBRARY_PATH=/app/data/library + - THUMBNAILS_PATH=/app/data/thumbnails # SvelteKit - ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For} - XFF_DEPTH=${TRUST_PROXY:-} diff --git a/package.json b/package.json index cae9662..b4eb70b 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@sveltejs/kit": "^2.22.2", "@sveltejs/vite-plugin-svelte": "^4.0.4", "@types/file-saver": "^2.0.7", - "@types/ms": "^2.1.0", + "@types/ms": "^0.7.34", "@types/node-schedule": "^2.1.7", "@types/pg": "^8.15.4", "autoprefixer": "^10.4.21", @@ -37,6 +37,7 @@ "globals": "^16.3.0", "heic2any": "^0.0.4", "kysely-ctl": "^0.13.1", + "lru-cache": "^11.1.0", "mime": "^4.0.7", "p-limit": "^6.2.0", "prettier": "^3.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7e04aa..79fec12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,8 +52,8 @@ importers: specifier: ^2.0.7 version: 2.0.7 '@types/ms': - specifier: ^2.1.0 - version: 2.1.0 + specifier: ^0.7.34 + version: 0.7.34 '@types/node-schedule': specifier: ^2.1.7 version: 2.1.7 @@ -96,6 +96,9 @@ importers: kysely-ctl: specifier: ^0.13.1 version: 0.13.1(kysely@0.28.2) + lru-cache: + specifier: ^11.1.0 + version: 11.1.0 mime: specifier: ^4.0.7 version: 4.0.7 @@ -592,8 +595,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/ms@0.7.34': + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} '@types/node-schedule@2.1.7': resolution: {integrity: sha512-G7Z3R9H7r3TowoH6D2pkzUHPhcJrDF4Jz1JOQ80AX0K2DWTHoN9VC94XzFAPNMdbW9TBzMZ3LjpFi7RYdbxtXA==} @@ -1290,6 +1293,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.6.1: resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==} engines: {node: '>=12'} @@ -2364,7 +2371,7 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/ms@2.1.0': {} + '@types/ms@0.7.34': {} '@types/node-schedule@2.1.7': dependencies: @@ -3099,6 +3106,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.1.0: {} + luxon@3.6.1: {} magic-string@0.30.17: diff --git a/src/lib/components/atoms/divs/FullscreenDiv.svelte b/src/lib/components/atoms/divs/FullscreenDiv.svelte index c90e02c..4bb1cc0 100644 --- a/src/lib/components/atoms/divs/FullscreenDiv.svelte +++ b/src/lib/components/atoms/divs/FullscreenDiv.svelte @@ -1,7 +1,15 @@ -
+
{@render children()}
diff --git a/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte b/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte index 5d4fb81..319e0df 100644 --- a/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte +++ b/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte @@ -10,19 +10,38 @@ name: string; subtext?: string; textClass?: ClassValue; + thumbnail?: string; type: "directory" | "file"; } - let { class: className, name, subtext, textClass: textClassName, type }: Props = $props(); + let { + class: className, + name, + subtext, + textClass: textClassName, + thumbnail, + type, + }: Props = $props(); +{#snippet iconSnippet()} +
+ {#if thumbnail} + {name} + {:else if type === "directory"} + + {:else} + + {/if} +
+{/snippet} + {#snippet subtextSnippet()} {subtext} {/snippet} ; + icon?: Component; iconClass?: ClassValue; + iconSnippet?: Snippet; subtext?: Snippet; textClass?: ClassValue; } @@ -16,15 +17,22 @@ class: className, icon: Icon, iconClass: iconClassName, + iconSnippet, subtext, textClass: textClassName, }: Props = $props();
-
- -
+ {#if iconSnippet} +
+ {@render iconSnippet()} +
+ {:else if Icon} +
+ +
+ {/if}

{@render children()} diff --git a/src/lib/components/organisms/Category/File.svelte b/src/lib/components/organisms/Category/File.svelte index 5263b95..7d49cf3 100644 --- a/src/lib/components/organisms/Category/File.svelte +++ b/src/lib/components/organisms/Category/File.svelte @@ -3,7 +3,7 @@ import { ActionEntryButton } from "$lib/components/atoms"; import { DirectoryEntryLabel } from "$lib/components/molecules"; import type { FileInfo } from "$lib/modules/filesystem"; - import type { SelectedFile } from "./service"; + import { requestFileThumbnailDownload, type SelectedFile } from "./service"; import IconClose from "~icons/material-symbols/close"; @@ -15,6 +15,8 @@ let { info, onclick, onRemoveClick }: Props = $props(); + let thumbnail: string | undefined = $state(); + const openFile = () => { const { id, dataKey, dataKeyVersion, name } = $info as FileInfo; if (!dataKey || !dataKeyVersion) return; // TODO: Error handling @@ -28,6 +30,21 @@ onRemoveClick!({ id, dataKey, dataKeyVersion, name }); }; + + $effect(() => { + if ($info?.dataKey) { + requestFileThumbnailDownload($info.id, $info.dataKey) + .then((thumbnailUrl) => { + thumbnail = thumbnailUrl ?? undefined; + }) + .catch(() => { + // TODO: Error Handling + thumbnail = undefined; + }); + } else { + thumbnail = undefined; + } + }); {#if $info} @@ -37,6 +54,6 @@ actionButtonIcon={onRemoveClick && IconClose} onActionButtonClick={removeFile} > - + {/if} diff --git a/src/lib/components/organisms/Category/service.ts b/src/lib/components/organisms/Category/service.ts index 1d587b5..fb6e640 100644 --- a/src/lib/components/organisms/Category/service.ts +++ b/src/lib/components/organisms/Category/service.ts @@ -1,3 +1,5 @@ +export { requestFileThumbnailDownload } from "$lib/services/file"; + export interface SelectedFile { id: number; dataKey: CryptoKey; diff --git a/src/lib/modules/file/cache.ts b/src/lib/modules/file/cache.ts index fe3c66c..31eac28 100644 --- a/src/lib/modules/file/cache.ts +++ b/src/lib/modules/file/cache.ts @@ -1,12 +1,15 @@ +import { LRUCache } from "lru-cache"; import { getFileCacheIndex as getFileCacheIndexFromIndexedDB, storeFileCacheIndex, deleteFileCacheIndex, type FileCacheIndex, } from "$lib/indexedDB"; -import { readFile, writeFile, deleteFile } from "$lib/modules/opfs"; +import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs"; +import { getThumbnailUrl } from "$lib/modules/thumbnail"; const fileCacheIndex = new Map(); +const loadedThumbnails = new LRUCache({ max: 100 }); export const prepareFileCache = async () => { for (const cache of await getFileCacheIndexFromIndexedDB()) { @@ -48,3 +51,32 @@ export const deleteFileCache = async (fileId: number) => { await deleteFile(`/cache/${fileId}`); await deleteFileCacheIndex(fileId); }; + +export const getFileThumbnailCache = async (fileId: number) => { + const thumbnail = loadedThumbnails.get(fileId); + if (thumbnail) { + return thumbnail; + } + + const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`); + if (!thumbnailBuffer) return null; + + const thumbnailUrl = getThumbnailUrl(thumbnailBuffer); + loadedThumbnails.set(fileId, thumbnailUrl); + return thumbnailUrl; +}; + +export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => { + await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); + loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer)); +}; + +export const deleteFileThumbnailCache = async (fileId: number) => { + loadedThumbnails.delete(fileId); + await deleteFile(`/thumbnail/file/${fileId}`); +}; + +export const deleteAllFileThumbnailCaches = async () => { + loadedThumbnails.clear(); + await deleteDirectory("/thumbnail/file"); +}; diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.ts index 71a38fb..b5b00a1 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 { generateThumbnail } from "$lib/modules/thumbnail"; import type { DuplicateFileScanRequest, DuplicateFileScanResponse, + FileThumbnailUploadRequest, FileUploadRequest, FileUploadResponse, } from "$lib/server/schemas"; @@ -106,6 +108,10 @@ const encryptFile = limitFunction( createdAt && (await encryptString(createdAt.getTime().toString(), dataKey)); const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey); + const thumbnail = await generateThumbnail(fileBuffer, fileType); + const thumbnailBuffer = await thumbnail?.arrayBuffer(); + const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey)); + status.update((value) => { value.status = "upload-pending"; return value; @@ -120,13 +126,14 @@ const encryptFile = limitFunction( nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, + thumbnail: thumbnailEncrypted && { plaintext: thumbnailBuffer, ...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 +151,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; @@ -160,7 +176,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, @@ -198,6 +216,7 @@ export const uploadFile = async ( nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, + thumbnail, } = await encryptFile(status, file, fileBuffer, masterKey); const form = new FormData(); @@ -218,13 +237,26 @@ 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); - const { fileId } = await requestFileUpload(status, form); - return { fileId, fileBuffer }; + let thumbnailForm = null; + if (thumbnail) { + thumbnailForm = new FormData(); + thumbnailForm.set( + "metadata", + JSON.stringify({ + dekVersion: dataKeyVersion.toISOString(), + contentIv: thumbnail.iv, + } satisfies FileThumbnailUploadRequest), + ); + thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); + } + + const { fileId } = await requestFileUpload(status, form, thumbnailForm); + return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; } catch (e) { status.update((value) => { value.status = "error"; diff --git a/src/lib/modules/opfs.ts b/src/lib/modules/opfs.ts index 5ac70da..41f1f72 100644 --- a/src/lib/modules/opfs.ts +++ b/src/lib/modules/opfs.ts @@ -59,3 +59,39 @@ export const deleteFile = async (path: string) => { await parentHandle.removeEntry(filename); }; + +const getDirectoryHandle = async (path: string) => { + if (!rootHandle) { + throw new Error("OPFS not prepared"); + } else if (path[0] !== "/") { + throw new Error("Path must be absolute"); + } + + const parts = path.split("/"); + if (parts.length <= 1) { + throw new Error("Invalid path"); + } + + try { + let directoryHandle = rootHandle; + let parentHandle; + for (const part of parts.slice(1)) { + if (!part) continue; + parentHandle = directoryHandle; + directoryHandle = await directoryHandle.getDirectoryHandle(part); + } + return { directoryHandle, parentHandle }; + } catch (e) { + if (e instanceof DOMException && e.name === "NotFoundError") { + return {}; + } + throw e; + } +}; + +export const deleteDirectory = async (path: string) => { + const { directoryHandle, parentHandle } = await getDirectoryHandle(path); + if (!parentHandle) return; + + await parentHandle.removeEntry(directoryHandle.name, { recursive: true }); +}; diff --git a/src/lib/modules/thumbnail.ts b/src/lib/modules/thumbnail.ts new file mode 100644 index 0000000..1a24b5d --- /dev/null +++ b/src/lib/modules/thumbnail.ts @@ -0,0 +1,111 @@ +import { encodeToBase64 } from "$lib/modules/crypto"; + +const scaleSize = (width: number, height: number, targetSize: number) => { + if (width <= targetSize || height <= targetSize) { + return { width, height }; + } + + const scale = targetSize / Math.min(width, height); + return { + width: Math.round(width * scale), + height: Math.round(height * scale), + }; +}; + +const generateImageThumbnail = (imageUrl: string) => { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + const canvas = document.createElement("canvas"); + const { width, height } = scaleSize(image.width, image.height, 250); + + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext("2d"); + if (!context) { + return reject(new Error("Failed to generate thumbnail")); + } + + context.drawImage(image, 0, 0, width, height); + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Failed to generate thumbnail")); + } + }, "image/webp"); + }; + image.onerror = reject; + + image.src = imageUrl; + }); +}; + +const generateVideoThumbnail = (videoUrl: string, time = 0) => { + return new Promise((resolve, reject) => { + const video = document.createElement("video"); + video.onloadeddata = () => { + video.currentTime = time; + }; + video.onseeked = () => { + const canvas = document.createElement("canvas"); + const { width, height } = scaleSize(video.videoWidth, video.videoHeight, 250); + + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext("2d"); + if (!context) { + return reject(new Error("Failed to generate thumbnail")); + } + + context.drawImage(video, 0, 0, width, height); + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Failed to generate thumbnail")); + } + }, "image/webp"); + }; + video.onerror = reject; + + video.muted = true; + video.playsInline = true; + video.src = videoUrl; + }); +}; + +export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: string) => { + let url; + try { + if (fileType === "image/heic") { + const { default: heic2any } = await import("heic2any"); + url = URL.createObjectURL( + (await heic2any({ + blob: new Blob([fileBuffer], { type: fileType }), + toType: "image/png", + })) as Blob, + ); + return await generateImageThumbnail(url); + } else 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 { + return null; + } finally { + if (url) { + URL.revokeObjectURL(url); + } + } +}; + +export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => { + return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`; +}; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index 20343b2..db450c7 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -163,16 +163,24 @@ export const unregisterDirectory = async (userId: number, directoryId: number) = .setIsolationLevel("repeatable read") // TODO: Sufficient? .execute(async (trx) => { const unregisterFiles = async (parentId: number) => { - return await trx + const files = await trx + .selectFrom("file") + .leftJoin("thumbnail", "file.id", "thumbnail.file_id") + .select(["file.id", "file.path", "thumbnail.path as thumbnailPath"]) + .where("file.parent_id", "=", parentId) + .where("file.user_id", "=", userId) + .forUpdate("file") + .execute(); + await trx .deleteFrom("file") .where("parent_id", "=", parentId) .where("user_id", "=", userId) - .returning(["id", "path"]) .execute(); + return files; }; const unregisterDirectoryRecursively = async ( directoryId: number, - ): Promise<{ id: number; path: string }[]> => { + ): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => { const files = await unregisterFiles(directoryId); const subDirectories = await trx .selectFrom("directory") @@ -327,7 +335,8 @@ export const getAllFilesByCategory = async ( .where("user_id", "=", userId) .where("file_id", "is not", null) .$narrowType<{ file_id: NotNull }>() - .orderBy(["file_id", "depth"]) + .orderBy("file_id") + .orderBy("depth") .execute(); return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 })); }; @@ -344,7 +353,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) => { @@ -416,16 +425,22 @@ export const setFileEncName = async ( }; export const unregisterFile = async (userId: number, fileId: number) => { - const file = await db - .deleteFrom("file") - .where("id", "=", fileId) - .where("user_id", "=", userId) - .returning("path") - .executeTakeFirst(); - if (!file) { - throw new IntegrityError("File not found"); - } - return { path: file.path }; + return await db.transaction().execute(async (trx) => { + const file = await trx + .selectFrom("file") + .leftJoin("thumbnail", "file.id", "thumbnail.file_id") + .select(["file.path", "thumbnail.path as thumbnailPath"]) + .where("file.id", "=", fileId) + .where("file.user_id", "=", userId) + .forUpdate("file") + .executeTakeFirst(); + if (!file) { + throw new IntegrityError("File not found"); + } + + await trx.deleteFrom("file").where("id", "=", fileId).execute(); + return file; + }); }; export const addFileToCategory = async (fileId: number, categoryId: number) => { diff --git a/src/lib/server/db/media.ts b/src/lib/server/db/media.ts new file mode 100644 index 0000000..209e256 --- /dev/null +++ b/src/lib/server/db/media.ts @@ -0,0 +1,110 @@ +import type { NotNull } from "kysely"; +import { IntegrityError } from "./error"; +import db from "./kysely"; + +interface Thumbnail { + id: number; + path: string; + updatedAt: Date; + encContentIv: string; +} + +interface FileThumbnail extends Thumbnail { + fileId: number; +} + +export const updateFileThumbnail = async ( + userId: number, + fileId: number, + dekVersion: Date, + path: string, + encContentIv: string, +) => { + return await db.transaction().execute(async (trx) => { + const file = await trx + .selectFrom("file") + .select("data_encryption_key_version") + .where("id", "=", fileId) + .where("user_id", "=", userId) + .limit(1) + .forUpdate() + .executeTakeFirst(); + if (!file) { + throw new IntegrityError("File not found"); + } else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) { + throw new IntegrityError("Invalid DEK version"); + } + + const thumbnail = await trx + .selectFrom("thumbnail") + .select("path as oldPath") + .where("file_id", "=", fileId) + .limit(1) + .forUpdate() + .executeTakeFirst(); + const now = new Date(); + + await trx + .insertInto("thumbnail") + .values({ + file_id: fileId, + path, + updated_at: now, + encrypted_content_iv: encContentIv, + }) + .onConflict((oc) => + oc.column("file_id").doUpdateSet({ + path, + updated_at: now, + encrypted_content_iv: encContentIv, + }), + ) + .execute(); + return thumbnail?.oldPath ?? null; + }); +}; + +export const getFileThumbnail = async (userId: number, fileId: number) => { + const thumbnail = await db + .selectFrom("thumbnail") + .innerJoin("file", "thumbnail.file_id", "file.id") + .selectAll("thumbnail") + .where("file.id", "=", fileId) + .where("file.user_id", "=", userId) + .$narrowType<{ file_id: NotNull }>() + .limit(1) + .executeTakeFirst(); + return thumbnail + ? ({ + id: thumbnail.id, + fileId: thumbnail.file_id, + path: thumbnail.path, + encContentIv: thumbnail.encrypted_content_iv, + updatedAt: thumbnail.updated_at, + } 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(({ id }) => id); +}; diff --git a/src/lib/server/db/migrations/1738409340-AddThumbnail.ts b/src/lib/server/db/migrations/1738409340-AddThumbnail.ts new file mode 100644 index 0000000..c3ce806 --- /dev/null +++ b/src/lib/server/db/migrations/1738409340-AddThumbnail.ts @@ -0,0 +1,31 @@ +import { Kysely, sql } from "kysely"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const up = async (db: Kysely) => { + // media.ts + await db.schema + .createTable("thumbnail") + .addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity()) + .addColumn("directory_id", "integer", (col) => + col.references("directory.id").onDelete("cascade").unique(), + ) + .addColumn("file_id", "integer", (col) => + col.references("file.id").onDelete("cascade").unique(), + ) + .addColumn("category_id", "integer", (col) => + col.references("category.id").onDelete("cascade").unique(), + ) + .addColumn("path", "text", (col) => col.unique().notNull()) + .addColumn("updated_at", "timestamp(3)", (col) => col.notNull()) + .addColumn("encrypted_content_iv", "text", (col) => col.notNull()) + .addCheckConstraint( + "thumbnail_ck01", + sql`(file_id IS NOT NULL)::integer + (directory_id IS NOT NULL)::integer + (category_id IS NOT NULL)::integer = 1`, + ) + .execute(); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const down = async (db: Kysely) => { + await db.schema.dropTable("thumbnail").execute(); +}; diff --git a/src/lib/server/db/migrations/index.ts b/src/lib/server/db/migrations/index.ts index aa6ee13..f58c2d0 100644 --- a/src/lib/server/db/migrations/index.ts +++ b/src/lib/server/db/migrations/index.ts @@ -1,7 +1,9 @@ import * as Initial1737357000 from "./1737357000-Initial"; import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory"; +import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail"; export default { "1737357000-Initial": Initial1737357000, "1737422340-AddFileCategory": AddFileCategory1737422340, + "1738409340-AddThumbnail": AddThumbnail1738409340, }; diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index d3dd9b1..4e427fb 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -2,6 +2,7 @@ export * from "./category"; export * from "./client"; export * from "./file"; export * from "./hsk"; +export * from "./media"; export * from "./mek"; export * from "./session"; export * from "./user"; diff --git a/src/lib/server/db/schema/media.ts b/src/lib/server/db/schema/media.ts new file mode 100644 index 0000000..ebfbf29 --- /dev/null +++ b/src/lib/server/db/schema/media.ts @@ -0,0 +1,17 @@ +import type { Generated } from "kysely"; + +interface ThumbnailTable { + id: Generated; + directory_id: number | null; + file_id: number | null; + category_id: number | null; + path: string; + updated_at: Date; + encrypted_content_iv: string; // Base64 +} + +declare module "./index" { + interface Database { + thumbnail: ThumbnailTable; + } +} diff --git a/src/lib/server/loadenv.ts b/src/lib/server/loadenv.ts index d6f4675..3a805d8 100644 --- a/src/lib/server/loadenv.ts +++ b/src/lib/server/loadenv.ts @@ -25,4 +25,5 @@ export default { sessionUpgradeExp: ms(env.SESSION_UPGRADE_CHALLENGE_EXPIRES || "5m"), }, libraryPath: env.LIBRARY_PATH || "library", + thumbnailsPath: env.THUMBNAILS_PATH || "thumbnails", }; diff --git a/src/lib/server/schemas/file.ts b/src/lib/server/schemas/file.ts index b6aa648..d0687b7 100644 --- a/src/lib/server/schemas/file.ts +++ b/src/lib/server/schemas/file.ts @@ -30,6 +30,18 @@ export const fileRenameRequest = z.object({ }); export type FileRenameRequest = z.infer; +export const fileThumbnailInfoResponse = z.object({ + updatedAt: z.string().datetime(), + contentIv: z.string().base64().nonempty(), +}); +export type FileThumbnailInfoResponse = z.infer; + +export const fileThumbnailUploadRequest = z.object({ + dekVersion: z.string().datetime(), + contentIv: z.string().base64().nonempty(), +}); +export type FileThumbnailUploadRequest = z.infer; + export const duplicateFileScanRequest = z.object({ hskVersion: z.number().int().positive(), contentHmac: z.string().base64().nonempty(), @@ -41,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/directory.ts b/src/lib/server/services/directory.ts index 2525069..fdab587 100644 --- a/src/lib/server/services/directory.ts +++ b/src/lib/server/services/directory.ts @@ -34,12 +34,19 @@ export const getDirectoryInformation = async (userId: number, directoryId: Direc }; }; +const safeUnlink = async (path: string | null) => { + if (path) { + await unlink(path).catch(console.error); + } +}; + export const deleteDirectory = async (userId: number, directoryId: number) => { try { const files = await unregisterDirectory(userId, directoryId); return { - files: files.map(({ id, path }) => { - unlink(path); // Intended + files: files.map(({ id, path, thumbnailPath }) => { + safeUnlink(path); // Intended + safeUnlink(thumbnailPath); // Intended return id; }), }; diff --git a/src/lib/server/services/file.ts b/src/lib/server/services/file.ts index d0b35ef..0e20676 100644 --- a/src/lib/server/services/file.ts +++ b/src/lib/server/services/file.ts @@ -16,6 +16,11 @@ import { getAllFileCategories, type NewFile, } from "$lib/server/db/file"; +import { + updateFileThumbnail, + getFileThumbnail, + getMissingFileThumbnails, +} from "$lib/server/db/media"; import type { Ciphertext } from "$lib/server/db/schema"; import env from "$lib/server/loadenv"; @@ -40,10 +45,17 @@ export const getFileInformation = async (userId: number, fileId: number) => { }; }; +const safeUnlink = async (path: string | null) => { + if (path) { + await unlink(path).catch(console.error); + } +}; + export const deleteFile = async (userId: number, fileId: number) => { try { - const { path } = await unregisterFile(userId, fileId); - unlink(path); // Intended + const { path, thumbnailPath } = await unregisterFile(userId, fileId); + safeUnlink(path); // Intended + safeUnlink(thumbnailPath); // Intended } catch (e) { if (e instanceof IntegrityError && e.message === "File not found") { error(404, "Invalid file id"); @@ -85,17 +97,69 @@ export const renameFile = async ( } }; +export const getFileThumbnailInformation = async (userId: number, fileId: number) => { + const thumbnail = await getFileThumbnail(userId, fileId); + if (!thumbnail) { + error(404, "File or its thumbnail not found"); + } + + return { updatedAt: thumbnail.updatedAt, encContentIv: thumbnail.encContentIv }; +}; + +export const getFileThumbnailStream = async (userId: number, fileId: number) => { + const thumbnail = await 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, + }; +}; + +export const uploadFileThumbnail = async ( + userId: number, + fileId: number, + dekVersion: Date, + encContentIv: string, + encContentStream: Readable, +) => { + const path = `${env.thumbnailsPath}/${userId}/${uuidv4()}`; + await mkdir(dirname(path), { recursive: true }); + + try { + await pipeline(encContentStream, createWriteStream(path, { flags: "wx", mode: 0o600 })); + + const oldPath = await updateFileThumbnail(userId, fileId, dekVersion, path, encContentIv); + safeUnlink(oldPath); // Intended + } catch (e) { + await safeUnlink(path); + + if (e instanceof IntegrityError) { + if (e.message === "File not found") { + error(404, "File not found"); + } else if (e.message === "Invalid DEK version") { + error(400, "Mismatched DEK version"); + } + } + throw e; + } +}; + export const scanDuplicateFiles = async ( userId: number, hskVersion: number, contentHmac: string, ) => { const fileIds = await getAllFileIdsByContentHmac(userId, hskVersion, contentHmac); - return { files: fileIds.map(({ id }) => id) }; + return { files: fileIds }; }; -const safeUnlink = async (path: string) => { - await unlink(path).catch(console.error); +export const scanMissingFileThumbnails = async (userId: number) => { + const fileIds = await getMissingFileThumbnails(userId); + return { files: fileIds }; }; export const uploadFile = async ( diff --git a/src/lib/services/file.ts b/src/lib/services/file.ts new file mode 100644 index 0000000..9ee6e1d --- /dev/null +++ b/src/lib/services/file.ts @@ -0,0 +1,43 @@ +import { callGetApi } from "$lib/hooks"; +import { decryptData } from "$lib/modules/crypto"; +import { + getFileCache, + storeFileCache, + getFileThumbnailCache, + storeFileThumbnailCache, + downloadFile, +} from "$lib/modules/file"; +import { getThumbnailUrl } from "$lib/modules/thumbnail"; +import type { FileThumbnailInfoResponse } 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 requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => { + const cache = await getFileThumbnailCache(fileId); + if (cache) return cache; + + let res = await callGetApi(`/api/file/${fileId}/thumbnail`); + if (!res.ok) return null; + + const { contentIv: thumbnailEncryptedIv }: FileThumbnailInfoResponse = await res.json(); + + res = await callGetApi(`/api/file/${fileId}/thumbnail/download`); + if (!res.ok) return null; + + const thumbnailEncrypted = await res.arrayBuffer(); + const thumbnailBuffer = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey); + + storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended + return getThumbnailUrl(thumbnailBuffer); +}; diff --git a/src/routes/(fullscreen)/file/[id]/service.ts b/src/routes/(fullscreen)/file/[id]/service.ts index 43f0134..ccedcd1 100644 --- a/src/routes/(fullscreen)/file/[id]/service.ts +++ b/src/routes/(fullscreen)/file/[id]/service.ts @@ -1,21 +1,8 @@ import { callPostApi } from "$lib/hooks"; -import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file"; import type { CategoryFileAddRequest } from "$lib/server/schemas"; export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category"; - -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 { requestFileDownload } from "$lib/services/file"; export const requestFileAdditionToCategory = async (fileId: number, categoryId: number) => { const res = await callPostApi(`/api/category/${categoryId}/file/add`, { diff --git a/src/routes/(fullscreen)/settings/cache/+page.svelte b/src/routes/(fullscreen)/settings/cache/+page.svelte index 262aacf..af375c2 100644 --- a/src/routes/(fullscreen)/settings/cache/+page.svelte +++ b/src/routes/(fullscreen)/settings/cache/+page.svelte @@ -4,12 +4,11 @@ import { FullscreenDiv } from "$lib/components/atoms"; import { TopBar } from "$lib/components/molecules"; import type { FileCacheIndex } from "$lib/indexedDB"; - import { getFileCacheIndex } from "$lib/modules/file"; + import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file"; import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import { formatFileSize } from "$lib/modules/util"; import { masterKeyStore } from "$lib/stores"; import File from "./File.svelte"; - import { deleteFileCache as doDeleteFileCache } from "./service"; interface FileCache { index: FileCacheIndex; diff --git a/src/routes/(fullscreen)/settings/cache/service.ts b/src/routes/(fullscreen)/settings/cache/service.ts deleted file mode 100644 index 35b0251..0000000 --- a/src/routes/(fullscreen)/settings/cache/service.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { deleteFileCache as doDeleteFileCache } from "$lib/modules/file"; - -export const deleteFileCache = async (fileId: number) => { - await doDeleteFileCache(fileId); -}; diff --git a/src/routes/(fullscreen)/settings/thumbnails/+page.svelte b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte new file mode 100644 index 0000000..d9cd692 --- /dev/null +++ b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte @@ -0,0 +1,77 @@ + + + + 썸네일 설정 + + + + +

+
+ + 저장된 썸네일 모두 삭제하기 + +
+ {#if persistentStates.files.length > 0} +
+

썸네일이 누락된 파일

+
+

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

+
+ {#each persistentStates.files as { info, status }} + goto(`/file/${id}`)} + onGenerateThumbnailClick={requestThumbnailGeneration} + /> + {/each} +
+
+
+ {/if} +
+ {#if persistentStates.files.length > 0} + + + + {/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..a16cb8e --- /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"; +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..8d413ec --- /dev/null +++ b/src/routes/(fullscreen)/settings/thumbnails/File.svelte @@ -0,0 +1,45 @@ + + + + +{#if $info} + onclick($info)} + actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined} + onActionButtonClick={() => onGenerateThumbnailClick($info)} + actionButtonClass="text-gray-800" + > + {@const subtext = + $generationStatus && $generationStatus !== "uploaded" + ? subtexts[$generationStatus] + : formatDateTime($info.createdAt ?? $info.lastModifiedAt)} + + +{/if} diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts new file mode 100644 index 0000000..97c00d2 --- /dev/null +++ b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts @@ -0,0 +1,114 @@ +import { limitFunction } from "p-limit"; +import { get, writable, type Writable } from "svelte/store"; +import { encryptData } from "$lib/modules/crypto"; +import { storeFileThumbnailCache } from "$lib/modules/file"; +import type { FileInfo } from "$lib/modules/filesystem"; +import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail"; +import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; +import { requestFileDownload } from "$lib/services/file"; + +export type GenerationStatus = + | "generation-pending" + | "generating" + | "upload-pending" + | "uploading" + | "uploaded" + | "error"; + +interface File { + id: number; + info: Writable; + status?: Writable; +} + +const workingFiles = new Map>(); + +export const persistentStates = $state({ + files: [] as File[], +}); + +export const getGenerationStatus = (fileId: number) => { + return workingFiles.get(fileId); +}; + +const generateThumbnail = limitFunction( + async ( + status: Writable, + fileBuffer: ArrayBuffer, + fileType: string, + dataKey: CryptoKey, + ) => { + status.set("generating"); + + const thumbnail = await doGenerateThumbnail(fileBuffer, fileType); + if (!thumbnail) { + status.set("error"); + return null; + } + + const thumbnailBuffer = await thumbnail.arrayBuffer(); + const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey); + status.set("upload-pending"); + return { plaintext: thumbnailBuffer, ...thumbnailEncrypted }; + }, + { concurrency: 4 }, +); + +const requestThumbnailUpload = limitFunction( + async ( + status: Writable, + fileId: number, + dataKeyVersion: Date, + thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: string }, + ) => { + status.set("uploading"); + + const form = new FormData(); + form.set( + "metadata", + JSON.stringify({ + dekVersion: dataKeyVersion.toISOString(), + contentIv: thumbnail.iv, + } satisfies FileThumbnailUploadRequest), + ); + form.set("content", new Blob([thumbnail.ciphertext])); + + const res = await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form }); + if (!res.ok) return false; + + status.set("uploaded"); + workingFiles.delete(fileId); + persistentStates.files = persistentStates.files.filter(({ id }) => id != fileId); + + storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended + return true; + }, + { concurrency: 4 }, +); + +export const requestThumbnailGeneration = async (fileInfo: FileInfo) => { + let status = workingFiles.get(fileInfo.id); + if (status && get(status) !== "error") return; + + status = writable("generation-pending"); + workingFiles.set(fileInfo.id, status); + persistentStates.files = persistentStates.files.map((file) => + file.id === fileInfo.id ? { ...file, status } : file, + ); + + try { + const file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey!); + const thumbnail = await generateThumbnail( + status, + file, + fileInfo.contentType, + fileInfo.dataKey!, + ); + if (!thumbnail) return; + if (!(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail))) { + status.set("error"); + } + } catch { + status.set("error"); + } +}; diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte index fd59d03..8251331 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte @@ -4,6 +4,7 @@ import { DirectoryEntryLabel } from "$lib/components/molecules"; import type { FileInfo } from "$lib/modules/filesystem"; import { formatDateTime } from "$lib/modules/util"; + import { requestFileThumbnailDownload } from "./service"; import type { SelectedEntry } from "../service.svelte"; import IconMoreVert from "~icons/material-symbols/more-vert"; @@ -16,6 +17,8 @@ let { info, onclick, onOpenMenuClick }: Props = $props(); + let thumbnail: string | undefined = $state(); + const openFile = () => { const { id, dataKey, dataKeyVersion, name } = $info!; if (!dataKey || !dataKeyVersion) return; // TODO: Error handling @@ -29,6 +32,21 @@ onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name }); }; + + $effect(() => { + if ($info?.dataKey) { + requestFileThumbnailDownload($info.id, $info.dataKey) + .then((thumbnailUrl) => { + thumbnail = thumbnailUrl ?? undefined; + }) + .catch(() => { + // TODO: Error Handling + thumbnail = undefined; + }); + } else { + thumbnail = undefined; + } + }); {#if $info} @@ -40,6 +58,7 @@ > diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte index 7977c53..a6df05a 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte @@ -13,8 +13,8 @@ {#if isFileUploading($status.status)} -
-
+
+
diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts new file mode 100644 index 0000000..d4b47f8 --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts @@ -0,0 +1 @@ +export { requestFileThumbnailDownload } from "$lib/services/file"; diff --git a/src/routes/(main)/directory/[[id]]/service.svelte.ts b/src/routes/(main)/directory/[[id]]/service.svelte.ts index 3c5f689..ba5fc4a 100644 --- a/src/routes/(main)/directory/[[id]]/service.svelte.ts +++ b/src/routes/(main)/directory/[[id]]/service.svelte.ts @@ -2,7 +2,13 @@ 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, + storeFileThumbnailCache, + deleteFileThumbnailCache, + uploadFile, +} from "$lib/modules/file"; import type { DirectoryRenameRequest, DirectoryCreateRequest, @@ -81,6 +87,10 @@ export const requestFileUpload = async ( if (!res) return false; storeFileCache(res.fileId, res.fileBuffer); // Intended + if (res.thumbnailBuffer) { + storeFileThumbnailCache(res.fileId, res.thumbnailBuffer); // Intended + } + return true; }; @@ -110,10 +120,12 @@ export const requestEntryDeletion = async (entry: SelectedEntry) => { if (entry.type === "directory") { const { deletedFiles }: DirectoryDeleteResponse = await res.json(); - await Promise.all(deletedFiles.map(deleteFileCache)); + await Promise.all( + deletedFiles.flatMap((fileId) => [deleteFileCache(fileId), deleteFileThumbnailCache(fileId)]), + ); return true; } else { - await deleteFileCache(entry.id); + await Promise.all([deleteFileCache(entry.id), deleteFileThumbnailCache(entry.id)]); return true; } }; 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/[id]/thumbnail/+server.ts b/src/routes/api/file/[id]/thumbnail/+server.ts new file mode 100644 index 0000000..12c9347 --- /dev/null +++ b/src/routes/api/file/[id]/thumbnail/+server.ts @@ -0,0 +1,26 @@ +import { error, json } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { fileThumbnailInfoResponse, type FileThumbnailInfoResponse } from "$lib/server/schemas"; +import { getFileThumbnailInformation } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ locals, params }) => { + const { userId } = await authorize(locals, "activeClient"); + + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!zodRes.success) error(400, "Invalid path parameters"); + const { id } = zodRes.data; + + const { updatedAt, encContentIv } = await getFileThumbnailInformation(userId, id); + return json( + fileThumbnailInfoResponse.parse({ + updatedAt: updatedAt.toISOString(), + contentIv: encContentIv, + } satisfies FileThumbnailInfoResponse), + ); +}; diff --git a/src/routes/api/file/[id]/thumbnail/download/+server.ts b/src/routes/api/file/[id]/thumbnail/download/+server.ts new file mode 100644 index 0000000..addd800 --- /dev/null +++ b/src/routes/api/file/[id]/thumbnail/download/+server.ts @@ -0,0 +1,25 @@ +import { error } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { getFileThumbnailStream } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ locals, params }) => { + const { userId } = await authorize(locals, "activeClient"); + + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(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, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": encContentSize.toString(), + }, + }); +}; diff --git a/src/routes/api/file/[id]/thumbnail/upload/+server.ts b/src/routes/api/file/[id]/thumbnail/upload/+server.ts new file mode 100644 index 0000000..62dfe42 --- /dev/null +++ b/src/routes/api/file/[id]/thumbnail/upload/+server.ts @@ -0,0 +1,74 @@ +import Busboy from "@fastify/busboy"; +import { error, text } from "@sveltejs/kit"; +import { Readable, Writable } from "stream"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { fileThumbnailUploadRequest, type FileThumbnailUploadRequest } from "$lib/server/schemas"; +import { uploadFileThumbnail } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ locals, params, request }) => { + const { userId } = await authorize(locals, "activeClient"); + + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!zodRes.success) error(400, "Invalid path parameters"); + const { id } = zodRes.data; + + const contentType = request.headers.get("Content-Type"); + if (!contentType?.startsWith("multipart/form-data") || !request.body) { + error(400, "Invalid request body"); + } + + return new Promise((resolve, reject) => { + const bb = Busboy({ headers: { "content-type": contentType } }); + const handler = + (f: (...args: T) => Promise) => + (...args: T) => { + f(...args).catch(reject); + }; + + let metadata: FileThumbnailUploadRequest | null = null; + let content: Readable | null = null; + bb.on( + "field", + handler(async (fieldname, val) => { + if (fieldname === "metadata") { + // Ignore subsequent metadata fields + if (!metadata) { + const zodRes = fileThumbnailUploadRequest.safeParse(JSON.parse(val)); + if (!zodRes.success) error(400, "Invalid request body"); + metadata = zodRes.data; + } + } else { + error(400, "Invalid request body"); + } + }), + ); + bb.on( + "file", + handler(async (fieldname, file) => { + if (fieldname !== "content") error(400, "Invalid request body"); + if (!metadata || content) error(400, "Invalid request body"); + content = file; + + await uploadFileThumbnail( + userId, + id, + new Date(metadata.dekVersion), + metadata.contentIv, + content, + ); + resolve(text("Thumbnail uploaded", { headers: { "Content-Type": "text/plain" } })); + }), + ); + bb.on("error", (e) => { + content?.emit("error", e) ?? reject(e); + }); + + request.body!.pipeTo(Writable.toWeb(bb)).catch(() => {}); // busboy will handle the error + }); +}; 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), + ); +};