From d98be331ad865cb64d66486e427ca9bf64e1d424 Mon Sep 17 00:00:00 2001 From: static Date: Thu, 1 Jan 2026 23:31:01 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=88,=20=EA=B0=A4=EB=9F=AC=EB=A6=AC,=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=84=A4=EC=A0=95,=20=EC=8D=B8=EB=84=A4?= =?UTF-8?q?=EC=9D=BC=20=EC=84=A4=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=ED=98=B8=EC=B6=9C=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/indexedDB/filesystem.ts | 4 + src/lib/modules/filesystem/file.ts | 80 +++++++++++++++++++ src/lib/modules/filesystem/internal.svelte.ts | 50 +++++++++++- src/lib/server/db/file.ts | 71 ++++++++++++++-- .../(fullscreen)/file/downloads/+page.svelte | 14 ++-- src/routes/(fullscreen)/gallery/+page.svelte | 6 +- .../(fullscreen)/settings/cache/+page.svelte | 18 +++-- .../settings/thumbnail/+page.svelte | 16 ++-- src/routes/(main)/home/+page.svelte | 11 ++- src/trpc/routers/file.ts | 33 ++++++++ 10 files changed, 267 insertions(+), 36 deletions(-) diff --git a/src/lib/indexedDB/filesystem.ts b/src/lib/indexedDB/filesystem.ts index 107009d..7be44c7 100644 --- a/src/lib/indexedDB/filesystem.ts +++ b/src/lib/indexedDB/filesystem.ts @@ -74,6 +74,10 @@ export const getFileInfo = async (id: number) => { return await filesystem.file.get(id); }; +export const bulkGetFileInfos = async (ids: number[]) => { + return await filesystem.file.bulkGet(ids); +}; + export const storeFileInfo = async (fileInfo: FileInfo) => { await filesystem.file.put(fileInfo); }; diff --git a/src/lib/modules/filesystem/file.ts b/src/lib/modules/filesystem/file.ts index fc6f53c..d8411bf 100644 --- a/src/lib/modules/filesystem/file.ts +++ b/src/lib/modules/filesystem/file.ts @@ -31,6 +31,38 @@ const fetchFromIndexedDB = async (id: number) => { } }; +const bulkFetchFromIndexedDB = async (ids: number[]) => { + const files = await IndexedDB.bulkGetFileInfos(ids); + const categories = await Promise.all( + files.map(async (file) => + file + ? await Promise.all( + file.categoryIds.map(async (categoryId) => { + const category = await IndexedDB.getCategoryInfo(categoryId); + return category ? { id: category.id, name: category.name } : undefined; + }), + ) + : undefined, + ), + ); + return new Map( + files + .map((file, index) => + file + ? ([ + file.id, + { + ...file, + exists: true, + categories: categories[index]!.filter((category) => !!category), + }, + ] as const) + : undefined, + ) + .filter((file) => !!file), + ); +}; + const fetchFromServer = async (id: number, masterKey: CryptoKey) => { try { const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id }); @@ -61,6 +93,35 @@ const fetchFromServer = async (id: number, masterKey: CryptoKey) => { } }; +const bulkFetchFromServer = async (ids: number[], masterKey: CryptoKey) => { + const filesRaw = await trpc().file.bulkGet.query({ ids }); + const files = await Promise.all( + filesRaw.map(async (file) => { + const categories = await Promise.all( + file.categories.map(async (category) => ({ + id: category.id, + ...(await decryptCategoryMetadata(category, masterKey)), + })), + ); + return { + id: file.id, + exists: true as const, + parentId: file.parent, + contentType: file.contentType, + contentIv: file.contentIv, + categories, + ...(await decryptFileMetadata(file, masterKey)), + }; + }), + ); + + const existingIds = new Set(filesRaw.map(({ id }) => id)); + return new Map([ + ...files.map((file) => [file.id, file] as const), + ...ids.filter((id) => !existingIds.has(id)).map((id) => [id, { id, exists: false }] as const), + ]); +}; + export const getFileInfo = async (id: number, masterKey: CryptoKey) => { return await cache.get(id, (isInitial, resolve) => monotonicResolve( @@ -69,3 +130,22 @@ export const getFileInfo = async (id: number, masterKey: CryptoKey) => { ), ); }; + +export const bulkGetFileInfo = async (ids: number[], masterKey: CryptoKey) => { + return await cache.bulkGet(new Set(ids), (keys, resolve) => + monotonicResolve( + [ + bulkFetchFromIndexedDB( + Array.from( + keys + .entries() + .filter(([, isInitial]) => isInitial) + .map(([key]) => key), + ), + ), + bulkFetchFromServer(Array.from(keys.keys()), masterKey), + ], + resolve, + ), + ); +}; diff --git a/src/lib/modules/filesystem/internal.svelte.ts b/src/lib/modules/filesystem/internal.svelte.ts index f98320d..8b2b092 100644 --- a/src/lib/modules/filesystem/internal.svelte.ts +++ b/src/lib/modules/filesystem/internal.svelte.ts @@ -17,7 +17,7 @@ export class FilesystemCache { loader(!info, (loadedInfo) => { if (!loadedInfo) return; - let info = this.map.get(key)!; + const info = this.map.get(key)!; if (info instanceof Promise) { const state = $state(loadedInfo); this.map.set(key, state as V); @@ -30,6 +30,54 @@ export class FilesystemCache { return info ?? promise; } + + async bulkGet( + keys: Set, + loader: (keys: Map, resolve: (values: Map) => void) => void, + ) { + const states = new Map(); + const promises = new Map>(); + const resolvers = new Map void>(); + + keys.forEach((key) => { + const info = this.map.get(key); + if (info instanceof Promise) { + promises.set(key, info); + } else if (info) { + states.set(key, info); + } else { + const { promise, resolve } = Promise.withResolvers(); + this.map.set(key, promise); + promises.set(key, promise); + resolvers.set(key, resolve); + } + }); + + loader( + new Map([ + ...states.keys().map((key) => [key, false] as const), + ...resolvers.keys().map((key) => [key, true] as const), + ]), + (loadedInfos) => + loadedInfos.forEach((loadedInfo, key) => { + const info = this.map.get(key)!; + const resolve = resolvers.get(key); + if (info instanceof Promise) { + const state = $state(loadedInfo); + this.map.set(key, state as V); + resolve?.(state as V); + } else { + Object.assign(info, loadedInfo); + resolve?.(info); + } + }), + ); + + const newStates = await Promise.all( + promises.entries().map(async ([key, promise]) => [key, await promise] as const), + ); + return new Map([...states, ...newStates]); + } } export const decryptDirectoryMetadata = async ( diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index 45ae0f4..6a0a062 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -1,4 +1,5 @@ import { sql } from "kysely"; +import { jsonArrayFrom } from "kysely/helpers/postgres"; import pg from "pg"; import { IntegrityError } from "./error"; import db from "./kysely"; @@ -36,6 +37,14 @@ interface File { export type NewFile = Omit; +interface FileCategory { + id: number; + mekVersion: number; + encDek: string; + dekVersion: Date; + encName: Ciphertext; +} + export const registerDirectory = async (params: NewDirectory) => { await db.transaction().execute(async (trx) => { const mek = await trx @@ -400,6 +409,51 @@ export const getFile = async (userId: number, fileId: number) => { : null; }; +export const getFilesWithCategories = async (userId: number, fileIds: number[]) => { + const files = await db + .selectFrom("file") + .selectAll() + .select((eb) => + jsonArrayFrom( + eb + .selectFrom("file_category") + .innerJoin("category", "file_category.category_id", "category.id") + .where("file_category.file_id", "=", eb.ref("file.id")) + .selectAll("category"), + ).as("categories"), + ) + .where("id", "=", (eb) => eb.fn.any(eb.val(fileIds))) + .where("user_id", "=", userId) + .execute(); + return files.map( + (file) => + ({ + id: file.id, + parentId: file.parent_id ?? "root", + userId: file.user_id, + path: file.path, + mekVersion: file.master_encryption_key_version, + encDek: file.encrypted_data_encryption_key, + dekVersion: file.data_encryption_key_version, + hskVersion: file.hmac_secret_key_version, + contentHmac: file.content_hmac, + contentType: file.content_type, + encContentIv: file.encrypted_content_iv, + encContentHash: file.encrypted_content_hash, + encName: file.encrypted_name, + encCreatedAt: file.encrypted_created_at, + encLastModifiedAt: file.encrypted_last_modified_at, + categories: file.categories.map((category) => ({ + id: category.id, + mekVersion: category.master_encryption_key_version, + encDek: category.encrypted_data_encryption_key, + dekVersion: new Date(category.data_encryption_key_version), + encName: category.encrypted_name, + })), + }) satisfies File & { categories: FileCategory[] }, + ); +}; + export const setFileEncName = async ( userId: number, fileId: number, @@ -490,13 +544,16 @@ export const getAllFileCategories = async (fileId: number) => { .selectAll("category") .where("file_id", "=", fileId) .execute(); - return categories.map((category) => ({ - id: category.id, - mekVersion: category.master_encryption_key_version, - encDek: category.encrypted_data_encryption_key, - dekVersion: category.data_encryption_key_version, - encName: category.encrypted_name, - })); + return categories.map( + (category) => + ({ + id: category.id, + mekVersion: category.master_encryption_key_version, + encDek: category.encrypted_data_encryption_key, + dekVersion: category.data_encryption_key_version, + encName: category.encrypted_name, + }) satisfies FileCategory, + ); }; export const removeFileFromCategory = async (fileId: number, categoryId: number) => { diff --git a/src/routes/(fullscreen)/file/downloads/+page.svelte b/src/routes/(fullscreen)/file/downloads/+page.svelte index b7b7136..5865db8 100644 --- a/src/routes/(fullscreen)/file/downloads/+page.svelte +++ b/src/routes/(fullscreen)/file/downloads/+page.svelte @@ -2,13 +2,16 @@ import { FullscreenDiv } from "$lib/components/atoms"; import { TopBar } from "$lib/components/molecules"; import { getDownloadingFiles, clearDownloadedFiles } from "$lib/modules/file"; - import { getFileInfo } from "$lib/modules/filesystem"; + import { bulkGetFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; import File from "./File.svelte"; const downloadingFiles = getDownloadingFiles(); const filesPromise = $derived( - Promise.all(downloadingFiles.map(({ id }) => getFileInfo(id, $masterKeyStore?.get(1)?.key!))), + bulkGetFileInfo( + downloadingFiles.map(({ id }) => id), + $masterKeyStore?.get(1)?.key!, + ), ); $effect(() => clearDownloadedFiles); @@ -22,9 +25,10 @@ {#await filesPromise then files}
- {#each files as file, index} - {#if file.exists} - + {#each downloadingFiles as state} + {@const info = files.get(state.id)!} + {#if info.exists} + {/if} {/each}
diff --git a/src/routes/(fullscreen)/gallery/+page.svelte b/src/routes/(fullscreen)/gallery/+page.svelte index 39ae445..01eed54 100644 --- a/src/routes/(fullscreen)/gallery/+page.svelte +++ b/src/routes/(fullscreen)/gallery/+page.svelte @@ -4,7 +4,7 @@ import { FullscreenDiv } from "$lib/components/atoms"; import { TopBar } from "$lib/components/molecules"; import { Gallery } from "$lib/components/organisms"; - import { getFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; + import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; let { data } = $props(); @@ -12,9 +12,7 @@ let files: MaybeFileInfo[] = $state([]); onMount(async () => { - files = await Promise.all( - data.files.map((file) => getFileInfo(file, $masterKeyStore?.get(1)?.key!)), - ); + files = Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values()); }); diff --git a/src/routes/(fullscreen)/settings/cache/+page.svelte b/src/routes/(fullscreen)/settings/cache/+page.svelte index b37701f..7884b20 100644 --- a/src/routes/(fullscreen)/settings/cache/+page.svelte +++ b/src/routes/(fullscreen)/settings/cache/+page.svelte @@ -4,7 +4,7 @@ import { TopBar } from "$lib/components/molecules"; import type { FileCacheIndex } from "$lib/indexedDB"; import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file"; - import { getFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; + import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; import { formatFileSize } from "$lib/utils"; import File from "./File.svelte"; @@ -23,13 +23,17 @@ }; onMount(async () => { - fileCache = await Promise.all( - getFileCacheIndex().map(async (index) => ({ - index, - info: await getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!), - })), + const indexes = getFileCacheIndex(); + const infos = await bulkGetFileInfo( + indexes.map(({ fileId }) => fileId), + $masterKeyStore?.get(1)?.key!, ); - fileCache.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime()); + fileCache = indexes + .map((index, i) => ({ + index, + info: infos.get(index.fileId)!, + })) + .sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime()); }); $effect(() => { diff --git a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte index 127f5eb..a5b658e 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte @@ -4,7 +4,7 @@ import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms"; import { IconEntryButton, TopBar } from "$lib/components/molecules"; import { deleteAllFileThumbnailCaches } from "$lib/modules/file"; - import { getFileInfo } from "$lib/modules/filesystem"; + import { bulkGetFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; import File from "./File.svelte"; import { @@ -14,7 +14,6 @@ } from "./service.svelte"; import IconDelete from "~icons/material-symbols/delete"; - import { file } from "zod"; let { data } = $props(); @@ -27,13 +26,12 @@ }; onMount(async () => { - persistentStates.files = await Promise.all( - data.files.map(async (fileId) => ({ - id: fileId, - info: await getFileInfo(fileId, $masterKeyStore?.get(1)?.key!), - status: getGenerationStatus(fileId), - })), - ); + const fileInfos = await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!); + persistentStates.files = persistentStates.files.map(({ id, status }) => ({ + id, + info: fileInfos.get(id)!, + status, + })); }); diff --git a/src/routes/(main)/home/+page.svelte b/src/routes/(main)/home/+page.svelte index 07c601d..bf94bad 100644 --- a/src/routes/(main)/home/+page.svelte +++ b/src/routes/(main)/home/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from "svelte"; import { goto } from "$app/navigation"; import { EntryButton, FileThumbnailButton } from "$lib/components/atoms"; - import { getFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; + import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; import { requestFreshMediaFilesRetrieval } from "./service"; @@ -10,8 +10,13 @@ onMount(async () => { const files = await requestFreshMediaFilesRetrieval(); - mediaFiles = await Promise.all( - files.map(({ id }) => getFileInfo(id, $masterKeyStore?.get(1)?.key!)), + mediaFiles = Array.from( + ( + await bulkGetFileInfo( + files.map(({ id }) => id), + $masterKeyStore?.get(1)?.key!, + ) + ).values(), ); }); diff --git a/src/trpc/routers/file.ts b/src/trpc/routers/file.ts index a3ac5f6..b08bbf2 100644 --- a/src/trpc/routers/file.ts +++ b/src/trpc/routers/file.ts @@ -42,6 +42,39 @@ const fileRouter = router({ }; }), + bulkGet: roleProcedure["activeClient"] + .input( + z.object({ + ids: z.number().positive().array(), + }), + ) + .query(async ({ ctx, input }) => { + const files = await FileRepo.getFilesWithCategories(ctx.session.userId, input.ids); + return files.map((file) => ({ + id: file.id, + parent: file.parentId, + mekVersion: file.mekVersion, + dek: file.encDek, + dekVersion: file.dekVersion, + contentType: file.contentType, + contentIv: file.encContentIv, + name: file.encName.ciphertext, + nameIv: file.encName.iv, + createdAt: file.encCreatedAt?.ciphertext, + createdAtIv: file.encCreatedAt?.iv, + lastModifiedAt: file.encLastModifiedAt.ciphertext, + lastModifiedAtIv: file.encLastModifiedAt.iv, + categories: file.categories.map((category) => ({ + id: category.id, + mekVersion: category.mekVersion, + dek: category.encDek, + dekVersion: category.dekVersion, + name: category.encName.ciphertext, + nameIv: category.encName.iv, + })), + })); + }), + list: roleProcedure["activeClient"].query(async ({ ctx }) => { return await FileRepo.getAllFileIds(ctx.session.userId); }),