From ad0f3ff9504743ca1136645c0526d054cebfa62b Mon Sep 17 00:00:00 2001 From: static Date: Fri, 31 Jan 2025 00:37:23 +0900 Subject: [PATCH 01/18] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80/=EB=B9=84?= =?UTF-8?q?=EB=94=94=EC=98=A4=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/modules/thumbnail.ts | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/lib/modules/thumbnail.ts diff --git a/src/lib/modules/thumbnail.ts b/src/lib/modules/thumbnail.ts new file mode 100644 index 0000000..30e931e --- /dev/null +++ b/src/lib/modules/thumbnail.ts @@ -0,0 +1,76 @@ +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), + }; +}; + +export 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; + }); +}; + +export 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; + }); +}; From 2105b66cc3c274ecc026a49838b0403d8f7de120 Mon Sep 17 00:00:00 2001 From: static Date: Sat, 1 Feb 2025 20:33:41 +0900 Subject: [PATCH 02/18] =?UTF-8?q?DB=EC=97=90=20thumbnail=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migrations/1738409340-AddThumbnail.ts | 31 +++++++++++++++++++ src/lib/server/db/migrations/index.ts | 2 ++ src/lib/server/db/schema/index.ts | 1 + src/lib/server/db/schema/media.ts | 17 ++++++++++ 4 files changed, 51 insertions(+) create mode 100644 src/lib/server/db/migrations/1738409340-AddThumbnail.ts create mode 100644 src/lib/server/db/schema/media.ts 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..0e38647 --- /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("created_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..9eeccf7 --- /dev/null +++ b/src/lib/server/db/schema/media.ts @@ -0,0 +1,17 @@ +import type { ColumnType, Generated } from "kysely"; + +interface ThumbnailTable { + id: Generated; + directory_id: number | null; + file_id: number | null; + category_id: number | null; + path: string; + created_at: ColumnType; + encrypted_content_iv: string; // Base64 +} + +declare module "./index" { + interface Database { + thumbnail: ThumbnailTable; + } +} From 36d082e0f8b67152e40d98c762ba6b0adffb152e Mon Sep 17 00:00:00 2001 From: static Date: Sat, 5 Jul 2025 05:44:00 +0900 Subject: [PATCH 03/18] =?UTF-8?q?/api/file/[id]/thumbnail,=20/api/file/[id?= =?UTF-8?q?]/thumbnail/download,=20/api/file/[id]/thumbnail/upload=20Endpo?= =?UTF-8?q?int=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + docker-compose.yaml | 2 + src/lib/server/db/file.ts | 3 +- src/lib/server/db/media.ts | 86 +++++++++++++++++++ src/lib/server/db/schema/media.ts | 4 +- src/lib/server/loadenv.ts | 1 + src/lib/server/schemas/file.ts | 11 +++ src/lib/server/services/file.ts | 54 ++++++++++++ src/routes/api/file/[id]/thumbnail/+server.ts | 23 +++++ .../file/[id]/thumbnail/download/+server.ts | 25 ++++++ .../api/file/[id]/thumbnail/upload/+server.ts | 72 ++++++++++++++++ 11 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 src/lib/server/db/media.ts create mode 100644 src/routes/api/file/[id]/thumbnail/+server.ts create mode 100644 src/routes/api/file/[id]/thumbnail/download/+server.ts create mode 100644 src/routes/api/file/[id]/thumbnail/upload/+server.ts 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/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/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index 20343b2..2affc7d 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -327,7 +327,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 })); }; diff --git a/src/lib/server/db/media.ts b/src/lib/server/db/media.ts new file mode 100644 index 0000000..1f1e065 --- /dev/null +++ b/src/lib/server/db/media.ts @@ -0,0 +1,86 @@ +import type { NotNull } from "kysely"; +import { IntegrityError } from "./error"; +import db from "./kysely"; + +interface Thumbnail { + id: number; + path: string; + createdAt: 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 old_path") + .where("file_id", "=", fileId) + .limit(1) + .forUpdate() + .executeTakeFirst(); + const now = new Date(); + + await trx + .insertInto("thumbnail") + .values({ + file_id: fileId, + path, + created_at: now, + encrypted_content_iv: encContentIv, + }) + .onConflict((oc) => + oc.column("file_id").doUpdateSet({ + path, + created_at: now, + encrypted_content_iv: encContentIv, + }), + ) + .execute(); + return thumbnail?.old_path; + }); +}; + +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, + createdAt: thumbnail.created_at, + } satisfies FileThumbnail) + : null; +}; diff --git a/src/lib/server/db/schema/media.ts b/src/lib/server/db/schema/media.ts index 9eeccf7..ad593b4 100644 --- a/src/lib/server/db/schema/media.ts +++ b/src/lib/server/db/schema/media.ts @@ -1,4 +1,4 @@ -import type { ColumnType, Generated } from "kysely"; +import type { Generated } from "kysely"; interface ThumbnailTable { id: Generated; @@ -6,7 +6,7 @@ interface ThumbnailTable { file_id: number | null; category_id: number | null; path: string; - created_at: ColumnType; + created_at: Date; encrypted_content_iv: string; // Base64 } 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..4cf140f 100644 --- a/src/lib/server/schemas/file.ts +++ b/src/lib/server/schemas/file.ts @@ -30,6 +30,17 @@ export const fileRenameRequest = z.object({ }); export type FileRenameRequest = z.infer; +export const fileThumbnailInfoResponse = z.object({ + encContentIv: z.string().base64().nonempty(), +}); +export type FileThumbnailInfoResponse = z.infer; + +export const fileThumbnailUploadRequest = z.object({ + dekVersion: z.string().datetime(), + encContentIv: z.string().base64().nonempty(), +}); +export type FileThumbnailUploadRequest = z.infer; + export const duplicateFileScanRequest = z.object({ hskVersion: z.number().int().positive(), contentHmac: z.string().base64().nonempty(), diff --git a/src/lib/server/services/file.ts b/src/lib/server/services/file.ts index d0b35ef..83e5750 100644 --- a/src/lib/server/services/file.ts +++ b/src/lib/server/services/file.ts @@ -16,6 +16,7 @@ import { getAllFileCategories, type NewFile, } from "$lib/server/db/file"; +import { getFileThumbnail, updateFileThumbnail } from "$lib/server/db/media"; import type { Ciphertext } from "$lib/server/db/schema"; import env from "$lib/server/loadenv"; @@ -85,6 +86,59 @@ 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 { 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); + if (oldPath) { + 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, 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..ab9d48c --- /dev/null +++ b/src/routes/api/file/[id]/thumbnail/+server.ts @@ -0,0 +1,23 @@ +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 { encContentIv } = await getFileThumbnailInformation(userId, id); + return json( + fileThumbnailInfoResponse.parse({ 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..52b99a8 --- /dev/null +++ b/src/routes/api/file/[id]/thumbnail/upload/+server.ts @@ -0,0 +1,72 @@ +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) { + metadata = fileThumbnailUploadRequest.parse(val); + } + } 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.encContentIv, + 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 + }); +}; From c2362421363fee47892be3451cefd5eb417f5ba2 Mon Sep 17 00:00:00 2001 From: static Date: Sat, 5 Jul 2025 05:54:55 +0900 Subject: [PATCH 04/18] =?UTF-8?q?thumbnail=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EC=9D=98=20created=5Fat=20=EC=BB=AC=EB=9F=BC=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9D=84=20updated=5Fat=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/db/media.ts | 8 ++++---- src/lib/server/db/migrations/1738409340-AddThumbnail.ts | 2 +- src/lib/server/db/schema/media.ts | 2 +- src/lib/server/schemas/file.ts | 1 + src/lib/server/services/file.ts | 2 +- src/routes/api/file/[id]/thumbnail/+server.ts | 7 +++++-- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/lib/server/db/media.ts b/src/lib/server/db/media.ts index 1f1e065..fbd8976 100644 --- a/src/lib/server/db/media.ts +++ b/src/lib/server/db/media.ts @@ -5,7 +5,7 @@ import db from "./kysely"; interface Thumbnail { id: number; path: string; - createdAt: Date; + updatedAt: Date; encContentIv: string; } @@ -49,13 +49,13 @@ export const updateFileThumbnail = async ( .values({ file_id: fileId, path, - created_at: now, + updated_at: now, encrypted_content_iv: encContentIv, }) .onConflict((oc) => oc.column("file_id").doUpdateSet({ path, - created_at: now, + updated_at: now, encrypted_content_iv: encContentIv, }), ) @@ -80,7 +80,7 @@ export const getFileThumbnail = async (userId: number, fileId: number) => { fileId: thumbnail.file_id, path: thumbnail.path, encContentIv: thumbnail.encrypted_content_iv, - createdAt: thumbnail.created_at, + updatedAt: thumbnail.updated_at, } satisfies FileThumbnail) : null; }; diff --git a/src/lib/server/db/migrations/1738409340-AddThumbnail.ts b/src/lib/server/db/migrations/1738409340-AddThumbnail.ts index 0e38647..c3ce806 100644 --- a/src/lib/server/db/migrations/1738409340-AddThumbnail.ts +++ b/src/lib/server/db/migrations/1738409340-AddThumbnail.ts @@ -16,7 +16,7 @@ export const up = async (db: Kysely) => { col.references("category.id").onDelete("cascade").unique(), ) .addColumn("path", "text", (col) => col.unique().notNull()) - .addColumn("created_at", "timestamp(3)", (col) => col.notNull()) + .addColumn("updated_at", "timestamp(3)", (col) => col.notNull()) .addColumn("encrypted_content_iv", "text", (col) => col.notNull()) .addCheckConstraint( "thumbnail_ck01", diff --git a/src/lib/server/db/schema/media.ts b/src/lib/server/db/schema/media.ts index ad593b4..ebfbf29 100644 --- a/src/lib/server/db/schema/media.ts +++ b/src/lib/server/db/schema/media.ts @@ -6,7 +6,7 @@ interface ThumbnailTable { file_id: number | null; category_id: number | null; path: string; - created_at: Date; + updated_at: Date; encrypted_content_iv: string; // Base64 } diff --git a/src/lib/server/schemas/file.ts b/src/lib/server/schemas/file.ts index 4cf140f..1d7ccb5 100644 --- a/src/lib/server/schemas/file.ts +++ b/src/lib/server/schemas/file.ts @@ -31,6 +31,7 @@ export const fileRenameRequest = z.object({ export type FileRenameRequest = z.infer; export const fileThumbnailInfoResponse = z.object({ + updatedAt: z.string().datetime(), encContentIv: z.string().base64().nonempty(), }); export type FileThumbnailInfoResponse = z.infer; diff --git a/src/lib/server/services/file.ts b/src/lib/server/services/file.ts index 83e5750..7616739 100644 --- a/src/lib/server/services/file.ts +++ b/src/lib/server/services/file.ts @@ -92,7 +92,7 @@ export const getFileThumbnailInformation = async (userId: number, fileId: number error(404, "File or its thumbnail not found"); } - return { encContentIv: thumbnail.encContentIv }; + return { updatedAt: thumbnail.updatedAt, encContentIv: thumbnail.encContentIv }; }; export const getFileThumbnailStream = async (userId: number, fileId: number) => { diff --git a/src/routes/api/file/[id]/thumbnail/+server.ts b/src/routes/api/file/[id]/thumbnail/+server.ts index ab9d48c..7bc81ca 100644 --- a/src/routes/api/file/[id]/thumbnail/+server.ts +++ b/src/routes/api/file/[id]/thumbnail/+server.ts @@ -16,8 +16,11 @@ export const GET: RequestHandler = async ({ locals, params }) => { if (!zodRes.success) error(400, "Invalid path parameters"); const { id } = zodRes.data; - const { encContentIv } = await getFileThumbnailInformation(userId, id); + const { updatedAt, encContentIv } = await getFileThumbnailInformation(userId, id); return json( - fileThumbnailInfoResponse.parse({ encContentIv } satisfies FileThumbnailInfoResponse), + fileThumbnailInfoResponse.parse({ + updatedAt: updatedAt.toISOString(), + encContentIv, + } satisfies FileThumbnailInfoResponse), ); }; From eaf2d7f2020eed816d5b381befe4c1244bb21569 Mon Sep 17 00:00:00 2001 From: static Date: Sat, 5 Jul 2025 16:55:09 +0900 Subject: [PATCH 05/18] =?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" } })); From 9e6792096866d0eade1fb339e2d87e053da409d0 Mon Sep 17 00:00:00 2001 From: static Date: Sat, 5 Jul 2025 18:18:10 +0900 Subject: [PATCH 06/18] =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../labels/DirectoryEntryLabel.svelte | 31 +++++++++++++++++-- .../molecules/labels/IconLabel.svelte | 2 +- .../[[id]]/DirectoryEntries/File.svelte | 19 ++++++++++++ .../[[id]]/DirectoryEntries/service.ts | 17 ++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts diff --git a/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte b/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte index 5d4fb81..9878e26 100644 --- a/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte +++ b/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte @@ -10,18 +10,45 @@ name: string; subtext?: string; textClass?: ClassValue; + thumbnail?: ArrayBuffer; type: "directory" | "file"; } - let { class: className, name, subtext, textClass: textClassName, type }: Props = $props(); + let { + class: className, + name, + subtext, + textClass: textClassName, + 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} + {:else if type === "directory"} + + {:else} + + {/if} +
+{/snippet} + {#snippet subtextSnippet()} {subtext} {/snippet} ; + icon: Component | Snippet; iconClass?: ClassValue; subtext?: Snippet; textClass?: ClassValue; diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte index fd59d03..22870e6 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 { getFileThumbnail } 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: ArrayBuffer | 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) { + getFileThumbnail($info.id, $info.dataKey) + .then((thumbnailData) => { + thumbnail = thumbnailData ?? 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/service.ts b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts new file mode 100644 index 0000000..a14a866 --- /dev/null +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts @@ -0,0 +1,17 @@ +import { callGetApi } from "$lib/hooks"; +import { decryptData } from "$lib/modules/crypto"; +import type { FileThumbnailInfoResponse } from "$lib/server/schemas"; + +export const getFileThumbnail = async (fileId: number, dataKey: CryptoKey) => { + 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 thumbnail = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey); + return thumbnail; +}; From 3a637b14b429bb433a3d709dd717eaf05de41fd8 Mon Sep 17 00:00:00 2001 From: static Date: Sun, 6 Jul 2025 00:25:50 +0900 Subject: [PATCH 07/18] =?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), + ); +}; From 781642fed64d2b6814e6e0d0131495e0b1e97d1c Mon Sep 17 00:00:00 2001 From: static Date: Sun, 6 Jul 2025 05:36:05 +0900 Subject: [PATCH 08/18] =?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; }; From 8975a0200dcc8516cd6b58b7a2df1a1483eaa291 Mon Sep 17 00:00:00 2001 From: static Date: Sun, 6 Jul 2025 17:38:04 +0900 Subject: [PATCH 09/18] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=ED=95=A0=20=EA=B2=BD=EC=9A=B0=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=99=80=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=EB=90=9C=20=EC=8D=B8=EB=84=A4?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=ED=95=A8=EA=BB=98=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/db/file.ts | 40 +++++++++++++------ src/lib/server/db/media.ts | 4 +- src/lib/server/services/directory.ts | 11 ++++- src/lib/server/services/file.ts | 19 ++++----- .../(main)/directory/[[id]]/service.svelte.ts | 14 +++++-- 5 files changed, 59 insertions(+), 29 deletions(-) diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index 0a76b6d..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") @@ -417,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 index 360ed49..8386ffc 100644 --- a/src/lib/server/db/media.ts +++ b/src/lib/server/db/media.ts @@ -37,7 +37,7 @@ export const updateFileThumbnail = async ( const thumbnail = await trx .selectFrom("thumbnail") - .select("path as old_path") + .select("path as oldPath") .where("file_id", "=", fileId) .limit(1) .forUpdate() @@ -60,7 +60,7 @@ export const updateFileThumbnail = async ( }), ) .execute(); - return thumbnail?.old_path; + return thumbnail?.oldPath ?? null; }); }; 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 6f5af03..0e20676 100644 --- a/src/lib/server/services/file.ts +++ b/src/lib/server/services/file.ts @@ -45,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"); @@ -126,9 +133,7 @@ export const uploadFileThumbnail = async ( await pipeline(encContentStream, createWriteStream(path, { flags: "wx", mode: 0o600 })); const oldPath = await updateFileThumbnail(userId, fileId, dekVersion, path, encContentIv); - if (oldPath) { - safeUnlink(oldPath); // Intended - } + safeUnlink(oldPath); // Intended } catch (e) { await safeUnlink(path); @@ -157,10 +162,6 @@ export const scanMissingFileThumbnails = async (userId: number) => { return { files: fileIds }; }; -const safeUnlink = async (path: string) => { - await unlink(path).catch(console.error); -}; - export const uploadFile = async ( params: Omit, encContentStream: Readable, diff --git a/src/routes/(main)/directory/[[id]]/service.svelte.ts b/src/routes/(main)/directory/[[id]]/service.svelte.ts index d4a0556..b29630a 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, storeFileThumbnail, uploadFile } from "$lib/modules/file"; +import { + storeFileCache, + deleteFileCache, + storeFileThumbnail, + deleteFileThumbnail, + uploadFile, +} from "$lib/modules/file"; import type { DirectoryRenameRequest, DirectoryCreateRequest, @@ -114,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), deleteFileThumbnail(fileId)]), + ); return true; } else { - await deleteFileCache(entry.id); + await Promise.all([deleteFileCache(entry.id), deleteFileThumbnail(entry.id)]); return true; } }; From bcb969dc22389267c93d54acf42560e7855040a5 Mon Sep 17 00:00:00 2001 From: static Date: Sun, 6 Jul 2025 19:55:13 +0900 Subject: [PATCH 10/18] =?UTF-8?q?heic=20=ED=8C=8C=EC=9D=BC=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=EB=8F=84=20=ED=8C=8C=EC=9D=BC=EC=9D=98=20=EC=8D=B8?= =?UTF-8?q?=EB=84=A4=EC=9D=BC=EC=9D=B4=20=ED=91=9C=EC=8B=9C=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../labels/DirectoryEntryLabel.svelte | 4 +-- .../components/organisms/Category/File.svelte | 25 +++++++++++++++++-- .../components/organisms/Category/service.ts | 2 ++ src/lib/modules/file/upload.ts | 6 ++++- src/lib/services/file.ts | 21 ++++++++++++++++ .../settings/thumbnails/service.ts | 11 +++++++- .../DirectoryEntries/UploadingFile.svelte | 4 +-- .../[[id]]/DirectoryEntries/service.ts | 22 +--------------- 8 files changed, 66 insertions(+), 29 deletions(-) create mode 100644 src/lib/services/file.ts diff --git a/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte b/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte index e38b348..57523b5 100644 --- a/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte +++ b/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte @@ -25,9 +25,9 @@ {#snippet iconSnippet()} -
+
{#if thumbnail} - {name} + {name} {:else if type === "directory"} {:else} diff --git a/src/lib/components/organisms/Category/File.svelte b/src/lib/components/organisms/Category/File.svelte index 5263b95..23465c1 100644 --- a/src/lib/components/organisms/Category/File.svelte +++ b/src/lib/components/organisms/Category/File.svelte @@ -2,8 +2,9 @@ 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 type { SelectedFile } from "./service"; + import { requestFileThumbnailDownload, type SelectedFile } from "./service"; import IconClose from "~icons/material-symbols/close"; @@ -15,6 +16,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 +31,24 @@ onRemoveClick!({ id, dataKey, dataKeyVersion, name }); }; + + $effect(() => { + if ($info?.dataKey) { + getFileThumbnail($info.id) + .then( + (thumbnailUrl) => thumbnailUrl || requestFileThumbnailDownload($info.id, $info.dataKey!), + ) + .then((thumbnailUrl) => { + thumbnail = thumbnailUrl ?? undefined; + }) + .catch(() => { + // TODO: Error Handling + thumbnail = undefined; + }); + } else { + thumbnail = undefined; + } + }); {#if $info} @@ -37,6 +58,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/upload.ts b/src/lib/modules/file/upload.ts index b56375f..3f70b13 100644 --- a/src/lib/modules/file/upload.ts +++ b/src/lib/modules/file/upload.ts @@ -81,7 +81,11 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => { const generateThumbnail = async (file: File, fileType: string) => { let url; try { - if (fileType.startsWith("image/")) { + if (fileType === "image/heic") { + const { default: heic2any } = await import("heic2any"); + url = URL.createObjectURL((await heic2any({ blob: file, toType: "image/png" })) as Blob); + return await generateImageThumbnail(url); + } else if (fileType.startsWith("image/")) { url = URL.createObjectURL(file); return await generateImageThumbnail(url); } else if (fileType.startsWith("video/")) { diff --git a/src/lib/services/file.ts b/src/lib/services/file.ts new file mode 100644 index 0000000..70d8887 --- /dev/null +++ b/src/lib/services/file.ts @@ -0,0 +1,21 @@ +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 requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => { + 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 thumbnail = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey); + + storeFileThumbnail(fileId, thumbnail); // Intended + return getThumbnailUrl(thumbnail); +}; diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.ts b/src/routes/(fullscreen)/settings/thumbnails/service.ts index ad24954..e3c828c 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/service.ts +++ b/src/routes/(fullscreen)/settings/thumbnails/service.ts @@ -21,7 +21,16 @@ export const generateThumbnail = limitFunction( async (fileBuffer: ArrayBuffer, fileType: string) => { let url; try { - if (fileType.startsWith("image/")) { + 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/")) { 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 index 70d8887..d4b47f8 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts @@ -1,21 +1 @@ -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 requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => { - 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 thumbnail = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey); - - storeFileThumbnail(fileId, thumbnail); // Intended - return getThumbnailUrl(thumbnail); -}; +export { requestFileThumbnailDownload } from "$lib/services/file"; From 8fefbc1bcb29daa68d3a17a68722e1a9470fb73f Mon Sep 17 00:00:00 2001 From: static Date: Sun, 6 Jul 2025 23:17:48 +0900 Subject: [PATCH 11/18] =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/modules/file/upload.ts | 1 - src/lib/services/file.ts | 15 +- src/routes/(fullscreen)/file/[id]/service.ts | 15 +- .../settings/thumbnails/+page.svelte | 62 ++++----- .../settings/thumbnails/File.svelte | 24 +++- .../settings/thumbnails/service.svelte.ts | 129 ++++++++++++++++++ .../settings/thumbnails/service.ts | 73 ---------- 7 files changed, 185 insertions(+), 134 deletions(-) create mode 100644 src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts delete mode 100644 src/routes/(fullscreen)/settings/thumbnails/service.ts diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.ts index 3f70b13..e2652a1 100644 --- a/src/lib/modules/file/upload.ts +++ b/src/lib/modules/file/upload.ts @@ -94,7 +94,6 @@ const generateThumbnail = async (file: File, fileType: string) => { } return null; } catch { - // TODO: Error handling return null; } finally { if (url) { diff --git a/src/lib/services/file.ts b/src/lib/services/file.ts index 70d8887..57a0749 100644 --- a/src/lib/services/file.ts +++ b/src/lib/services/file.ts @@ -1,9 +1,22 @@ import { callGetApi } from "$lib/hooks"; import { decryptData } from "$lib/modules/crypto"; -import { storeFileThumbnail } from "$lib/modules/file"; +import { getFileCache, storeFileCache, downloadFile, storeFileThumbnail } 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) => { let res = await callGetApi(`/api/file/${fileId}/thumbnail`); if (!res.ok) return null; 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/thumbnails/+page.svelte b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte index f57d542..5be902e 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte @@ -1,52 +1,35 @@ @@ -56,19 +39,20 @@ - {#if fileInfos && fileInfos.length > 0} + {#if persistentStates.files.length > 0}

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

- {#each fileInfos as fileInfo} + {#each persistentStates.files as { info, status }} goto(`/file/${id}`)} - onGenerateThumbnailClick={generateThumbnail} + onGenerateThumbnailClick={requestFileThumbnailGeneration} /> {/each}
diff --git a/src/routes/(fullscreen)/settings/thumbnails/File.svelte b/src/routes/(fullscreen)/settings/thumbnails/File.svelte index d06d435..a9530e1 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/File.svelte +++ b/src/routes/(fullscreen)/settings/thumbnails/File.svelte @@ -1,9 +1,20 @@ + + {#if $info} @@ -24,10 +36,10 @@ 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..66d3e18 --- /dev/null +++ b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts @@ -0,0 +1,129 @@ +import { limitFunction } from "p-limit"; +import { get, writable, type Writable } from "svelte/store"; +import { encryptData } from "$lib/modules/crypto"; +import { storeFileThumbnail } from "$lib/modules/file"; +import type { FileInfo } from "$lib/modules/filesystem"; +import { generateImageThumbnail, generateVideoThumbnail } 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): Writable | undefined => { + return workingFiles.get(fileId); +}; + +const generateThumbnail = limitFunction( + async ( + status: Writable, + fileBuffer: ArrayBuffer, + fileType: string, + dataKey: CryptoKey, + ) => { + let url, thumbnail; + status.set("generating"); + + 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, + ); + thumbnail = await generateImageThumbnail(url); + } else if (fileType.startsWith("image/")) { + url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); + thumbnail = await generateImageThumbnail(url); + } else if (fileType.startsWith("video/")) { + url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); + thumbnail = await generateVideoThumbnail(url); + } else { + status.set("error"); + return null; + } + + const thumbnailBuffer = await thumbnail.arrayBuffer(); + const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey); + status.set("upload-pending"); + return { plaintext: thumbnailBuffer, ...thumbnailEncrypted }; + } catch { + status.set("error"); + return null; + } finally { + if (url) { + URL.revokeObjectURL(url); + } + } + }, + { 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); + + storeFileThumbnail(fileId, thumbnail.plaintext); // Intended + return true; + }, + { concurrency: 4 }, +); + +export const requestFileThumbnailGeneration = 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, + ); + + // TODO: Error Handling + const file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey!); + const thumbnail = await generateThumbnail(status, file, fileInfo.contentType, fileInfo.dataKey!); + if (!thumbnail) return; + + await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail); +}; diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.ts b/src/routes/(fullscreen)/settings/thumbnails/service.ts deleted file mode 100644 index e3c828c..0000000 --- a/src/routes/(fullscreen)/settings/thumbnails/service.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { limitFunction } from "p-limit"; -import { encryptData } from "$lib/modules/crypto"; -import { getFileCache, storeFileCache, downloadFile, storeFileThumbnail } 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 === "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 { - // 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 }); - if (!res.ok) return false; - - storeFileThumbnail(fileId, thumbnail); // Intended - return true; - }, - { concurrency: 4 }, -); From e4cce6b8a0d40773cea773cfc7f25b6563fbb842 Mon Sep 17 00:00:00 2001 From: static Date: Mon, 7 Jul 2025 00:30:38 +0900 Subject: [PATCH 12/18] =?UTF-8?q?OPFS=EC=97=90=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=EB=90=9C=20=EC=8D=B8=EB=84=A4=EC=9D=BC=EC=9D=84=20=EB=AA=A8?= =?UTF-8?q?=EB=91=90=20=EC=82=AD=EC=A0=9C=ED=95=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../atoms/divs/FullscreenDiv.svelte | 12 +++- src/lib/modules/file/thumbnail.ts | 7 ++- src/lib/modules/opfs.ts | 36 +++++++++++ .../(fullscreen)/settings/cache/+page.svelte | 3 +- .../(fullscreen)/settings/cache/service.ts | 5 -- .../settings/thumbnails/+page.svelte | 63 +++++++++++-------- .../settings/thumbnails/File.svelte | 2 +- .../settings/thumbnails/service.svelte.ts | 2 +- 8 files changed, 91 insertions(+), 39 deletions(-) delete mode 100644 src/routes/(fullscreen)/settings/cache/service.ts 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/modules/file/thumbnail.ts b/src/lib/modules/file/thumbnail.ts index e78786c..6757ffc 100644 --- a/src/lib/modules/file/thumbnail.ts +++ b/src/lib/modules/file/thumbnail.ts @@ -1,5 +1,5 @@ import { LRUCache } from "lru-cache"; -import { readFile, writeFile, deleteFile } from "$lib/modules/opfs"; +import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs"; import { getThumbnailUrl } from "$lib/modules/thumbnail"; const loadedThumbnails = new LRUCache({ max: 100 }); @@ -27,3 +27,8 @@ export const deleteFileThumbnail = async (fileId: number) => { loadedThumbnails.delete(fileId); await deleteFile(`/thumbnails/${fileId}`); }; + +export const deleteAllFileThumbnails = async () => { + loadedThumbnails.clear(); + await deleteDirectory("/thumbnails"); +}; 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/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 index 5be902e..a498f1c 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte @@ -3,23 +3,26 @@ import { get } from "svelte/store"; import { goto } from "$app/navigation"; import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms"; - import { TopBar } from "$lib/components/molecules"; + import { IconEntryButton, TopBar } from "$lib/components/molecules"; + import { deleteAllFileThumbnails } from "$lib/modules/file"; import { getFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; import File from "./File.svelte"; import { persistentStates, getGenerationStatus, - requestFileThumbnailGeneration, + requestThumbnailGeneration, } from "./service.svelte"; + import IconDelete from "~icons/material-symbols/delete"; + let { data } = $props(); - const generateAllThumbnails = async () => { + const generateAllThumbnails = () => { persistentStates.files.forEach(({ info }) => { const fileInfo = get(info); if (fileInfo) { - requestFileThumbnailGeneration(fileInfo); + requestThumbnailGeneration(fileInfo); } }); }; @@ -38,31 +41,37 @@ - - {#if persistentStates.files.length > 0} -
-
-

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

-
-
- {#each persistentStates.files as { info, status }} - goto(`/file/${id}`)} - onGenerateThumbnailClick={requestFileThumbnailGeneration} - /> - {/each} -
+ +
+
+ + 저장된 썸네일 모두 삭제하기 +
- + {#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} + - {:else} -
-

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

-
{/if}
diff --git a/src/routes/(fullscreen)/settings/thumbnails/File.svelte b/src/routes/(fullscreen)/settings/thumbnails/File.svelte index a9530e1..8d413ec 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/File.svelte +++ b/src/routes/(fullscreen)/settings/thumbnails/File.svelte @@ -32,7 +32,7 @@ onclick($info)} - actionButtonIcon={IconCamera} + actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined} onActionButtonClick={() => onGenerateThumbnailClick($info)} actionButtonClass="text-gray-800" > diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts index 66d3e18..a3f77ce 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts +++ b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts @@ -110,7 +110,7 @@ const requestThumbnailUpload = limitFunction( { concurrency: 4 }, ); -export const requestFileThumbnailGeneration = async (fileInfo: FileInfo) => { +export const requestThumbnailGeneration = async (fileInfo: FileInfo) => { let status = workingFiles.get(fileInfo.id); if (status && get(status) !== "error") return; From d3de06a7f97efda76854b8a8480d6d69484a9e2f Mon Sep 17 00:00:00 2001 From: static Date: Mon, 7 Jul 2025 17:48:55 +0900 Subject: [PATCH 13/18] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=B4=20=EB=9E=9C=EB=8D=94=EB=A7=81=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8D=98=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../molecules/labels/DirectoryEntryLabel.svelte | 5 ++--- .../components/molecules/labels/IconLabel.svelte | 14 ++++++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte b/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte index 57523b5..319e0df 100644 --- a/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte +++ b/src/lib/components/molecules/labels/DirectoryEntryLabel.svelte @@ -31,7 +31,7 @@ {:else if type === "directory"} {:else} - + {/if}
{/snippet} @@ -41,8 +41,7 @@ {/snippet} | Snippet; + icon?: Component; iconClass?: ClassValue; + iconSnippet?: Snippet; subtext?: Snippet; textClass?: ClassValue; } @@ -16,15 +17,20 @@ class: className, icon: Icon, iconClass: iconClassName, + iconSnippet, subtext, textClass: textClassName, }: Props = $props();
-
- -
+ {#if iconSnippet} + {@render iconSnippet()} + {:else if Icon} +
+ +
+ {/if}

{@render children()} From 40a87aa81ff250371c7d03078a5c77157338ed98 Mon Sep 17 00:00:00 2001 From: static Date: Mon, 7 Jul 2025 18:29:04 +0900 Subject: [PATCH 14/18] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=8D=B8=EB=84=A4?= =?UTF-8?q?=EC=9D=BC=EC=9D=B4=20=EC=BA=90=EC=8B=9C=EB=90=98=EB=8A=94=20OPF?= =?UTF-8?q?S=EC=9D=98=20=EA=B2=BD=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/organisms/Category/File.svelte | 6 +--- src/lib/modules/file/cache.ts | 34 ++++++++++++++++++- src/lib/modules/file/index.ts | 1 - src/lib/modules/file/thumbnail.ts | 34 ------------------- src/lib/services/file.ts | 13 +++++-- .../settings/thumbnails/+page.svelte | 4 +-- .../(fullscreen)/settings/thumbnails/+page.ts | 2 +- .../settings/thumbnails/service.svelte.ts | 4 +-- .../[[id]]/DirectoryEntries/File.svelte | 6 +--- .../(main)/directory/[[id]]/service.svelte.ts | 10 +++--- 10 files changed, 56 insertions(+), 58 deletions(-) delete mode 100644 src/lib/modules/file/thumbnail.ts diff --git a/src/lib/components/organisms/Category/File.svelte b/src/lib/components/organisms/Category/File.svelte index 23465c1..7d49cf3 100644 --- a/src/lib/components/organisms/Category/File.svelte +++ b/src/lib/components/organisms/Category/File.svelte @@ -2,7 +2,6 @@ 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 { requestFileThumbnailDownload, type SelectedFile } from "./service"; @@ -34,10 +33,7 @@ $effect(() => { if ($info?.dataKey) { - getFileThumbnail($info.id) - .then( - (thumbnailUrl) => thumbnailUrl || requestFileThumbnailDownload($info.id, $info.dataKey!), - ) + requestFileThumbnailDownload($info.id, $info.dataKey) .then((thumbnailUrl) => { thumbnail = thumbnailUrl ?? undefined; }) 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/index.ts b/src/lib/modules/file/index.ts index dc708ac..42a5613 100644 --- a/src/lib/modules/file/index.ts +++ b/src/lib/modules/file/index.ts @@ -1,4 +1,3 @@ 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 deleted file mode 100644 index 6757ffc..0000000 --- a/src/lib/modules/file/thumbnail.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { LRUCache } from "lru-cache"; -import { readFile, writeFile, deleteFile, deleteDirectory } 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}`); -}; - -export const deleteAllFileThumbnails = async () => { - loadedThumbnails.clear(); - await deleteDirectory("/thumbnails"); -}; diff --git a/src/lib/services/file.ts b/src/lib/services/file.ts index 57a0749..1a95538 100644 --- a/src/lib/services/file.ts +++ b/src/lib/services/file.ts @@ -1,6 +1,12 @@ import { callGetApi } from "$lib/hooks"; import { decryptData } from "$lib/modules/crypto"; -import { getFileCache, storeFileCache, downloadFile, storeFileThumbnail } from "$lib/modules/file"; +import { + getFileCache, + storeFileCache, + getFileThumbnailCache, + storeFileThumbnailCache, + downloadFile, +} from "$lib/modules/file"; import { getThumbnailUrl } from "$lib/modules/thumbnail"; import type { FileThumbnailInfoResponse } from "$lib/server/schemas"; @@ -18,6 +24,9 @@ export const requestFileDownload = async ( }; 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; @@ -29,6 +38,6 @@ export const requestFileThumbnailDownload = async (fileId: number, dataKey: Cryp const thumbnailEncrypted = await res.arrayBuffer(); const thumbnail = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey); - storeFileThumbnail(fileId, thumbnail); // Intended + storeFileThumbnailCache(fileId, thumbnail); // Intended return getThumbnailUrl(thumbnail); }; diff --git a/src/routes/(fullscreen)/settings/thumbnails/+page.svelte b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte index a498f1c..68c6d6f 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte @@ -4,7 +4,7 @@ import { goto } from "$app/navigation"; import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms"; import { IconEntryButton, TopBar } from "$lib/components/molecules"; - import { deleteAllFileThumbnails } from "$lib/modules/file"; + import { deleteAllFileThumbnailCaches } from "$lib/modules/file"; import { getFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; import File from "./File.svelte"; @@ -44,7 +44,7 @@

- + 저장된 썸네일 모두 삭제하기
diff --git a/src/routes/(fullscreen)/settings/thumbnails/+page.ts b/src/routes/(fullscreen)/settings/thumbnails/+page.ts index 3fc7cff..a16cb8e 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/+page.ts +++ b/src/routes/(fullscreen)/settings/thumbnails/+page.ts @@ -1,6 +1,6 @@ import { error } from "@sveltejs/kit"; import { callPostApi } from "$lib/hooks"; -import type { MissingThumbnailFileScanResponse } from "$lib/server/schemas/file"; +import type { MissingThumbnailFileScanResponse } from "$lib/server/schemas"; import type { PageLoad } from "./$types"; export const load: PageLoad = async ({ fetch }) => { diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts index a3f77ce..4e430d5 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts +++ b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts @@ -1,7 +1,7 @@ import { limitFunction } from "p-limit"; import { get, writable, type Writable } from "svelte/store"; import { encryptData } from "$lib/modules/crypto"; -import { storeFileThumbnail } from "$lib/modules/file"; +import { storeFileThumbnailCache } from "$lib/modules/file"; import type { FileInfo } from "$lib/modules/filesystem"; import { generateImageThumbnail, generateVideoThumbnail } from "$lib/modules/thumbnail"; import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; @@ -104,7 +104,7 @@ const requestThumbnailUpload = limitFunction( workingFiles.delete(fileId); persistentStates.files = persistentStates.files.filter(({ id }) => id != fileId); - storeFileThumbnail(fileId, thumbnail.plaintext); // Intended + storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended return true; }, { concurrency: 4 }, diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte index 4245898..8251331 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte @@ -2,7 +2,6 @@ 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 { requestFileThumbnailDownload } from "./service"; @@ -36,10 +35,7 @@ $effect(() => { if ($info?.dataKey) { - getFileThumbnail($info.id) - .then( - (thumbnailUrl) => thumbnailUrl || requestFileThumbnailDownload($info.id, $info.dataKey!), - ) + requestFileThumbnailDownload($info.id, $info.dataKey) .then((thumbnailUrl) => { thumbnail = thumbnailUrl ?? undefined; }) diff --git a/src/routes/(main)/directory/[[id]]/service.svelte.ts b/src/routes/(main)/directory/[[id]]/service.svelte.ts index b29630a..ba5fc4a 100644 --- a/src/routes/(main)/directory/[[id]]/service.svelte.ts +++ b/src/routes/(main)/directory/[[id]]/service.svelte.ts @@ -5,8 +5,8 @@ import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$ import { storeFileCache, deleteFileCache, - storeFileThumbnail, - deleteFileThumbnail, + storeFileThumbnailCache, + deleteFileThumbnailCache, uploadFile, } from "$lib/modules/file"; import type { @@ -88,7 +88,7 @@ export const requestFileUpload = async ( storeFileCache(res.fileId, res.fileBuffer); // Intended if (res.thumbnailBuffer) { - storeFileThumbnail(res.fileId, res.thumbnailBuffer); // Intended + storeFileThumbnailCache(res.fileId, res.thumbnailBuffer); // Intended } return true; @@ -121,11 +121,11 @@ export const requestEntryDeletion = async (entry: SelectedEntry) => { if (entry.type === "directory") { const { deletedFiles }: DirectoryDeleteResponse = await res.json(); await Promise.all( - deletedFiles.flatMap((fileId) => [deleteFileCache(fileId), deleteFileThumbnail(fileId)]), + deletedFiles.flatMap((fileId) => [deleteFileCache(fileId), deleteFileThumbnailCache(fileId)]), ); return true; } else { - await Promise.all([deleteFileCache(entry.id), deleteFileThumbnail(entry.id)]); + await Promise.all([deleteFileCache(entry.id), deleteFileThumbnailCache(entry.id)]); return true; } }; From 5d9042d149ec0e27fc7be6dbfbc07a71b64c883e Mon Sep 17 00:00:00 2001 From: static Date: Mon, 7 Jul 2025 23:09:43 +0900 Subject: [PATCH 15/18] =?UTF-8?q?=EC=84=B8=EB=A1=9C=EB=A1=9C=20=EA=B8=B4?= =?UTF-8?q?=20=EC=8D=B8=EB=84=A4=EC=9D=BC=EC=9D=B4=20=EC=A0=95=EC=82=AC?= =?UTF-8?q?=EA=B0=81=ED=98=95=EC=9C=BC=EB=A1=9C=20=EC=A0=9C=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=ED=91=9C=EC=8B=9C=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8D=98=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/components/molecules/labels/IconLabel.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/components/molecules/labels/IconLabel.svelte b/src/lib/components/molecules/labels/IconLabel.svelte index 158ddb2..c6cc8dc 100644 --- a/src/lib/components/molecules/labels/IconLabel.svelte +++ b/src/lib/components/molecules/labels/IconLabel.svelte @@ -25,7 +25,9 @@
{#if iconSnippet} - {@render iconSnippet()} +
+ {@render iconSnippet()} +
{:else if Icon}
From 9b1e27c20b042c07d0862afdbed04d30c9e83c87 Mon Sep 17 00:00:00 2001 From: static Date: Tue, 8 Jul 2025 02:07:54 +0900 Subject: [PATCH 16/18] =?UTF-8?q?=EC=82=AC=EC=86=8C=ED=95=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/modules/file/upload.ts | 28 +------------ src/lib/modules/thumbnail.ts | 33 ++++++++++++++- src/lib/server/db/media.ts | 2 +- src/lib/services/file.ts | 6 +-- .../settings/thumbnails/service.svelte.ts | 42 ++++--------------- 5 files changed, 46 insertions(+), 65 deletions(-) diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.ts index e2652a1..2c84f93 100644 --- a/src/lib/modules/file/upload.ts +++ b/src/lib/modules/file/upload.ts @@ -11,7 +11,7 @@ import { digestMessage, signMessageHmac, } from "$lib/modules/crypto"; -import { generateImageThumbnail, generateVideoThumbnail } from "$lib/modules/thumbnail"; +import { generateThumbnail } from "$lib/modules/thumbnail"; import type { DuplicateFileScanRequest, DuplicateFileScanResponse, @@ -78,30 +78,6 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => { return new Date(utcDate - offsetMs); }; -const generateThumbnail = async (file: File, fileType: string) => { - let url; - try { - if (fileType === "image/heic") { - const { default: heic2any } = await import("heic2any"); - url = URL.createObjectURL((await heic2any({ blob: file, toType: "image/png" })) as Blob); - return await generateImageThumbnail(url); - } else 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; - } catch { - return null; - } finally { - if (url) { - URL.revokeObjectURL(url); - } - } -}; - const encryptFile = limitFunction( async ( status: Writable, @@ -132,7 +108,7 @@ 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 thumbnail = await generateThumbnail(fileBuffer, fileType); const thumbnailBuffer = await thumbnail?.arrayBuffer(); const thumbnailEncrypted = thumbnailBuffer ? await encryptData(thumbnailBuffer, dataKey) : null; diff --git a/src/lib/modules/thumbnail.ts b/src/lib/modules/thumbnail.ts index 2352c65..1a24b5d 100644 --- a/src/lib/modules/thumbnail.ts +++ b/src/lib/modules/thumbnail.ts @@ -12,7 +12,7 @@ const scaleSize = (width: number, height: number, targetSize: number) => { }; }; -export const generateImageThumbnail = (imageUrl: string) => { +const generateImageThumbnail = (imageUrl: string) => { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { @@ -42,7 +42,7 @@ export const generateImageThumbnail = (imageUrl: string) => { }); }; -export const generateVideoThumbnail = (videoUrl: string, time = 0) => { +const generateVideoThumbnail = (videoUrl: string, time = 0) => { return new Promise((resolve, reject) => { const video = document.createElement("video"); video.onloadeddata = () => { @@ -77,6 +77,35 @@ export const generateVideoThumbnail = (videoUrl: string, time = 0) => { }); }; +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/media.ts b/src/lib/server/db/media.ts index 8386ffc..209e256 100644 --- a/src/lib/server/db/media.ts +++ b/src/lib/server/db/media.ts @@ -106,5 +106,5 @@ export const getMissingFileThumbnails = async (userId: number, limit: number = 1 ) .limit(limit) .execute(); - return files.map((file) => file.id); + return files.map(({ id }) => id); }; diff --git a/src/lib/services/file.ts b/src/lib/services/file.ts index 1a95538..9ee6e1d 100644 --- a/src/lib/services/file.ts +++ b/src/lib/services/file.ts @@ -36,8 +36,8 @@ export const requestFileThumbnailDownload = async (fileId: number, dataKey: Cryp if (!res.ok) return null; const thumbnailEncrypted = await res.arrayBuffer(); - const thumbnail = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey); + const thumbnailBuffer = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey); - storeFileThumbnailCache(fileId, thumbnail); // Intended - return getThumbnailUrl(thumbnail); + storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended + return getThumbnailUrl(thumbnailBuffer); }; diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts index 4e430d5..aaee616 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts +++ b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts @@ -3,7 +3,7 @@ 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 { generateImageThumbnail, generateVideoThumbnail } from "$lib/modules/thumbnail"; +import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail"; import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; import { requestFileDownload } from "$lib/services/file"; @@ -38,42 +38,18 @@ const generateThumbnail = limitFunction( fileType: string, dataKey: CryptoKey, ) => { - let url, thumbnail; status.set("generating"); - - 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, - ); - thumbnail = await generateImageThumbnail(url); - } else if (fileType.startsWith("image/")) { - url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); - thumbnail = await generateImageThumbnail(url); - } else if (fileType.startsWith("video/")) { - url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); - thumbnail = await generateVideoThumbnail(url); - } else { - status.set("error"); - return null; - } - - const thumbnailBuffer = await thumbnail.arrayBuffer(); - const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey); - status.set("upload-pending"); - return { plaintext: thumbnailBuffer, ...thumbnailEncrypted }; - } catch { + const thumbnail = await doGenerateThumbnail(fileBuffer, fileType); + if (!thumbnail) { status.set("error"); return null; - } finally { - if (url) { - URL.revokeObjectURL(url); - } } + + const thumbnailBuffer = await thumbnail.arrayBuffer(); + const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey); + + status.set("upload-pending"); + return { plaintext: thumbnailBuffer, ...thumbnailEncrypted }; }, { concurrency: 4 }, ); From a42ec281766c4d73a4846f61c4c749f71f983f7c Mon Sep 17 00:00:00 2001 From: static Date: Tue, 8 Jul 2025 02:26:51 +0900 Subject: [PATCH 17/18] =?UTF-8?q?=EC=82=AC=EC=86=8C=ED=95=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/modules/file/upload.ts | 5 ++--- src/routes/(fullscreen)/settings/thumbnails/+page.svelte | 4 ++-- .../(fullscreen)/settings/thumbnails/service.svelte.ts | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.ts index 2c84f93..b5b00a1 100644 --- a/src/lib/modules/file/upload.ts +++ b/src/lib/modules/file/upload.ts @@ -110,7 +110,7 @@ const encryptFile = limitFunction( const thumbnail = await generateThumbnail(fileBuffer, fileType); const thumbnailBuffer = await thumbnail?.arrayBuffer(); - const thumbnailEncrypted = thumbnailBuffer ? await encryptData(thumbnailBuffer, dataKey) : null; + const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey)); status.update((value) => { value.status = "upload-pending"; @@ -126,8 +126,7 @@ const encryptFile = limitFunction( nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, - thumbnail: thumbnail && - thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted }, + thumbnail: thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted }, }; }, { concurrency: 4 }, diff --git a/src/routes/(fullscreen)/settings/thumbnails/+page.svelte b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte index 68c6d6f..d9cd692 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnails/+page.svelte @@ -43,7 +43,7 @@
-
+
저장된 썸네일 모두 삭제하기 @@ -70,7 +70,7 @@ {/if}
{#if persistentStates.files.length > 0} - + {/if} diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts index aaee616..59e35f4 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts +++ b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts @@ -27,7 +27,7 @@ export const persistentStates = $state({ files: [] as File[], }); -export const getGenerationStatus = (fileId: number): Writable | undefined => { +export const getGenerationStatus = (fileId: number) => { return workingFiles.get(fileId); }; @@ -39,6 +39,7 @@ const generateThumbnail = limitFunction( dataKey: CryptoKey, ) => { status.set("generating"); + const thumbnail = await doGenerateThumbnail(fileBuffer, fileType); if (!thumbnail) { status.set("error"); @@ -47,7 +48,6 @@ const generateThumbnail = limitFunction( const thumbnailBuffer = await thumbnail.arrayBuffer(); const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey); - status.set("upload-pending"); return { plaintext: thumbnailBuffer, ...thumbnailEncrypted }; }, From 2c7d085e6d0bbaec31f39c4e674222ee2dec717e Mon Sep 17 00:00:00 2001 From: static Date: Tue, 8 Jul 2025 02:34:14 +0900 Subject: [PATCH 18/18] =?UTF-8?q?=EC=82=AC=EC=86=8C=ED=95=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/thumbnails/service.svelte.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts index 59e35f4..97c00d2 100644 --- a/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts +++ b/src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts @@ -96,10 +96,19 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => { file.id === fileInfo.id ? { ...file, status } : file, ); - // TODO: Error Handling - const file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey!); - const thumbnail = await generateThumbnail(status, file, fileInfo.contentType, fileInfo.dataKey!); - if (!thumbnail) return; - - await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail); + 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"); + } };