파일/디렉터리 목록 캐싱 추가

This commit is contained in:
static
2025-01-06 19:19:43 +09:00
parent 47850e1421
commit 10b7472ee9
13 changed files with 293 additions and 241 deletions

90
src/lib/modules/file.ts Normal file
View File

@@ -0,0 +1,90 @@
import { writable } from "svelte/store";
import { callGetApi } from "$lib/hooks";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
import {
directoryInfoStore,
fileInfoStore,
type DirectoryInfo,
type FileInfo,
} from "$lib/stores/file";
const fetchDirectoryInfo = async (directoryId: "root" | number, masterKey: CryptoKey) => {
const res = await callGetApi(`/api/directory/${directoryId}`);
if (!res.ok) throw new Error("Failed to fetch directory information");
const { metadata, subDirectories, files }: DirectoryInfoResponse = await res.json();
let newInfo: DirectoryInfo;
if (directoryId === "root") {
newInfo = {
id: "root",
subDirectoryIds: subDirectories,
fileIds: files,
};
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
newInfo = {
id: directoryId,
dataKey,
dataKeyVersion: metadata!.dekVersion,
name: await decryptString(metadata!.name, metadata!.nameIv, dataKey),
subDirectoryIds: subDirectories,
fileIds: files,
};
}
const info = directoryInfoStore.get(directoryId);
if (info) {
info.update(() => newInfo);
} else {
directoryInfoStore.set(directoryId, writable(newInfo));
}
};
export const getDirectoryInfo = (directoryId: "root" | number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = directoryInfoStore.get(directoryId);
if (!info) {
info = writable(null);
directoryInfoStore.set(directoryId, info);
}
fetchDirectoryInfo(directoryId, masterKey);
return info;
};
const fetchFileInfo = async (fileId: number, masterKey: CryptoKey) => {
const res = await callGetApi(`/api/file/${fileId}`);
if (!res.ok) throw new Error("Failed to fetch file information");
const metadata: FileInfoResponse = await res.json();
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const newInfo: FileInfo = {
id: fileId,
dataKey,
dataKeyVersion: metadata.dekVersion,
contentIv: metadata.contentIv,
name: await decryptString(metadata.name, metadata.nameIv, dataKey),
};
const info = fileInfoStore.get(fileId);
if (info) {
info.update(() => newInfo);
} else {
fileInfoStore.set(fileId, writable(newInfo));
}
};
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = fileInfoStore.get(fileId);
if (!info) {
info = writable(null);
fileInfoStore.set(fileId, info);
}
fetchFileInfo(fileId, masterKey);
return info;
};

View File

@@ -1,11 +0,0 @@
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import type { FileInfoResponse } from "$lib/server/schemas";
export const decryptFileMetadata = async (metadata: FileInfoResponse, masterKey: CryptoKey) => {
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
return {
dataKey,
dataKeyVersion: metadata.dekVersion,
name: await decryptString(metadata.name, metadata.nameIv, dataKey),
};
};

30
src/lib/stores/file.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { Writable } from "svelte/store";
export type DirectoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export interface FileInfo {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
contentIv: string;
name: string;
}
export const directoryInfoStore = new Map<"root" | number, Writable<DirectoryInfo | null>>();
export const fileInfoStore = new Map<number, Writable<FileInfo | null>>();

View File

@@ -1 +1,2 @@
export * from "./file";
export * from "./key";

View File

@@ -1,32 +1,27 @@
<script lang="ts">
import FileSaver from "file-saver";
import type { Writable } from "svelte/store";
import { TopBar } from "$lib/components";
import { masterKeyStore } from "$lib/stores";
import { decryptFileMetadata, requestFileDownload } from "./service";
import { getFileInfo } from "$lib/modules/file";
import { masterKeyStore, type FileInfo } from "$lib/stores";
import { requestFileDownload } from "./service";
let { data } = $props();
let metadata = $state<Awaited<ReturnType<typeof decryptFileMetadata>> | undefined>();
let info: Writable<FileInfo | null> | undefined = $state();
let isDownloaded = $state(false);
$effect(() => {
if ($masterKeyStore) {
decryptFileMetadata(data.metadata, $masterKeyStore.get(data.metadata.mekVersion)!.key).then(
async (_metadata) => {
metadata = _metadata;
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
isDownloaded = false;
});
const file = await requestFileDownload(
data.id,
data.metadata.contentIv,
_metadata.dataKey,
);
// TODO: Preview
const blob = new Blob([file]);
FileSaver.saveAs(blob, metadata.name);
},
);
$effect(() => {
if (info && $info && !isDownloaded) {
isDownloaded = true;
requestFileDownload(data.id, $info.contentIv, $info.dataKey).then((content) => {
FileSaver.saveAs(new Blob([content], { type: "application/octet-stream" }), $info.name);
});
}
});
</script>
@@ -35,8 +30,4 @@
<title>파일</title>
</svelte:head>
{#if metadata}
<TopBar title={metadata.name} />
{:else}
<TopBar />
{/if}
<TopBar title={$info?.name} />

View File

@@ -1,10 +1,8 @@
import { error } from "@sveltejs/kit";
import { z } from "zod";
import { callGetApi } from "$lib/hooks";
import type { FileInfoResponse } from "$lib/server/schemas";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ params, fetch }) => {
export const load: PageLoad = async ({ params }) => {
const zodRes = z
.object({
id: z.coerce.number().int().positive(),
@@ -13,12 +11,5 @@ export const load: PageLoad = async ({ params, fetch }) => {
if (!zodRes.success) error(404, "Not found");
const { id } = zodRes.data;
const res = await callGetApi(`/api/file/${id}`, fetch);
if (!res.ok) error(404, "Not found");
const fileInfo: FileInfoResponse = await res.json();
return {
id,
metadata: fileInfo,
};
return { id };
};

View File

@@ -1,7 +1,5 @@
import { decryptData } from "$lib/modules/crypto";
export { decryptFileMetadata } from "$lib/services/file";
export const requestFileDownload = (
fileId: number,
fileEncryptedIv: string,

View File

@@ -1,17 +1,18 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components";
import { FloatingButton } from "$lib/components/buttons";
import { masterKeyStore } from "$lib/stores";
import { getDirectoryInfo, getFileInfo } from "$lib/modules/file";
import { masterKeyStore, type DirectoryInfo } from "$lib/stores";
import CreateBottomSheet from "./CreateBottomSheet.svelte";
import CreateDirectoryModal from "./CreateDirectoryModal.svelte";
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";
import DirectoryEntry from "./DirectoryEntry.svelte";
import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte";
import File from "./File.svelte";
import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte";
import SubDirectory from "./SubDirectory.svelte";
import {
decryptDirectoryMetadata,
decryptFileMetadata,
requestDirectoryCreation,
requestFileUpload,
requestDirectoryEntryRename,
@@ -23,6 +24,7 @@
let { data } = $props();
let info: Writable<DirectoryInfo | null> | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state();
let selectedEntry: SelectedDirectoryEntry | undefined = $state();
@@ -33,48 +35,6 @@
let isRenameDirectoryEntryModalOpen = $state(false);
let isDeleteDirectoryEntryModalOpen = $state(false);
// TODO: FIX ME
const metadata = $derived.by(() => {
const { metadata } = data;
if (metadata && $masterKeyStore) {
return decryptDirectoryMetadata(metadata, $masterKeyStore.get(metadata.mekVersion)!.key);
}
});
const subDirectories = $derived.by(() => {
const { subDirectories } = data;
if ($masterKeyStore) {
return Promise.all(
subDirectories.map(async (subDirectory) => {
const metadata = subDirectory.metadata!;
return {
...(await decryptDirectoryMetadata(
metadata,
$masterKeyStore.get(metadata.mekVersion)!.key,
)),
id: subDirectory.id,
};
}),
).then((subDirectories) => {
subDirectories.sort((a, b) => a.name.localeCompare(b.name));
return subDirectories;
});
}
});
const files = $derived.by(() => {
const { files } = data;
if ($masterKeyStore) {
return Promise.all(
files.map(async (file) => ({
...(await decryptFileMetadata(file!, $masterKeyStore.get(file.mekVersion)!.key)),
id: file.id,
})),
).then((files) => {
files.sort((a, b) => a.name.localeCompare(b.name));
return files;
});
}
});
const createDirectory = async (name: string) => {
await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!);
isCreateDirectoryModalOpen = false;
@@ -86,6 +46,10 @@
requestFileUpload(file, data.id, $masterKeyStore?.get(1)!);
};
$effect(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
</script>
<svelte:head>
@@ -94,50 +58,42 @@
<input bind:this={fileInput} onchange={uploadFile} type="file" class="hidden" />
<div class="px-4">
{#if data.id !== "root"}
{#if !metadata}
<TopBar />
{:else}
{#await metadata}
<TopBar />
{:then metadata}
<TopBar title={metadata.name} />
{/await}
{/if}
{/if}
<div class="my-4 pb-[4.5rem]">
{#if subDirectories}
{#await subDirectories then subDirectories}
{#each subDirectories as { id, dataKey, dataKeyVersion, name }}
<DirectoryEntry
{name}
onclick={() => goto(`/directory/${id}`)}
onOpenMenuClick={() => {
selectedEntry = { type: "directory", id, dataKey, dataKeyVersion, name };
isDirectoryEntryMenuBottomSheetOpen = true;
}}
type="directory"
/>
{/each}
{/await}
{/if}
{#if files}
{#await files then files}
{#each files as { id, dataKey, dataKeyVersion, name }}
<DirectoryEntry
{name}
onclick={() => goto(`/file/${id}`)}
onOpenMenuClick={() => {
selectedEntry = { type: "file", id, dataKey, dataKeyVersion, name };
isDirectoryEntryMenuBottomSheetOpen = true;
}}
type="file"
/>
{/each}
{/await}
<div class="flex min-h-full flex-col px-4">
<div class="flex-shrink-0">
{#if data.id !== "root"}
<TopBar title={$info?.name} />
{/if}
</div>
{#if $info && $info.subDirectoryIds.length + $info.fileIds.length > 0}
<div class="my-4 pb-[4.5rem]">
{#each $info.subDirectoryIds as subDirectoryId}
{@const subDirectoryInfo = getDirectoryInfo(subDirectoryId, $masterKeyStore?.get(1)?.key!)}
<SubDirectory
info={subDirectoryInfo}
onclick={() => goto(`/directory/${subDirectoryId}`)}
onOpenMenuClick={({ id, dataKey, dataKeyVersion, name }) => {
selectedEntry = { type: "directory", id, dataKey, dataKeyVersion, name };
isDirectoryEntryMenuBottomSheetOpen = true;
}}
/>
{/each}
{#each $info.fileIds as fileId}
{@const fileInfo = getFileInfo(fileId, $masterKeyStore?.get(1)?.key!)}
<File
info={fileInfo}
onclick={() => goto(`/file/${fileId}`)}
onOpenMenuClick={({ dataKey, id, dataKeyVersion, name }) => {
selectedEntry = { type: "file", id, dataKey, dataKeyVersion, name };
isDirectoryEntryMenuBottomSheetOpen = true;
}}
/>
{/each}
</div>
{:else}
<div class="my-4 flex flex-grow items-center justify-center">
<p class="text-gray-500">폴더가 비어있어요.</p>
</div>
{/if}
</div>
<FloatingButton

View File

@@ -1,10 +1,8 @@
import { error } from "@sveltejs/kit";
import { z } from "zod";
import { callGetApi } from "$lib/hooks";
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ params, fetch }) => {
export const load: PageLoad = async ({ params }) => {
const zodRes = z
.object({
id: z.coerce.number().int().positive().optional(),
@@ -13,36 +11,7 @@ export const load: PageLoad = async ({ params, fetch }) => {
if (!zodRes.success) error(404, "Not found");
const { id } = zodRes.data;
const directoryId = id ? id : ("root" as const);
const res = await callGetApi(`/api/directory/${directoryId}`, fetch);
if (!res.ok) error(404, "Not found");
const directoryInfo: DirectoryInfoResponse = await res.json();
const subDirectoryInfos = await Promise.all(
directoryInfo.subDirectories.map(async (subDirectoryId) => {
const res = await callGetApi(`/api/directory/${subDirectoryId}`, fetch);
if (!res.ok) error(500, "Internal server error");
return {
...((await res.json()) as DirectoryInfoResponse),
id: subDirectoryId,
};
}),
);
const fileInfos = await Promise.all(
directoryInfo.files.map(async (fileId) => {
const res = await callGetApi(`/api/file/${fileId}`, fetch);
if (!res.ok) error(500, "Internal server error");
return {
...((await res.json()) as FileInfoResponse),
id: fileId,
};
}),
);
return {
id: directoryId,
metadata: directoryInfo.metadata,
subDirectories: subDirectoryInfos,
files: fileInfos,
id: id ? id : ("root" as const),
};
};

View File

@@ -1,54 +0,0 @@
<script lang="ts">
import IconFolder from "~icons/material-symbols/folder";
import IconDraft from "~icons/material-symbols/draft";
import IconMoreVert from "~icons/material-symbols/more-vert";
interface Props {
name: string;
onclick: () => void;
onOpenMenuClick: () => void;
type: "directory" | "file";
}
let { name, onclick, onOpenMenuClick, type }: Props = $props();
const openMenu = (e: Event) => {
e.stopPropagation();
setTimeout(() => {
onOpenMenuClick();
}, 100);
};
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div id="button" onclick={() => setTimeout(onclick, 100)} class="h-12 w-full rounded-xl">
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
<div class="flex-shrink-0 text-lg">
{#if type === "directory"}
<IconFolder />
{:else if type === "file"}
<IconDraft class="text-blue-400" />
{/if}
</div>
<p title={name} class="flex-grow truncate font-medium">
{name}
</p>
<button
id="open-menu"
onclick={openMenu}
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
>
<IconMoreVert class="text-lg transition active:scale-95" />
</button>
</div>
</div>
<style>
#button:active:not(:has(#open-menu:active)) {
@apply bg-gray-100;
}
#button-content:active:not(:has(#open-menu:active)) {
@apply scale-95;
}
</style>

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileInfo } from "$lib/stores";
import IconDraft from "~icons/material-symbols/draft";
import IconMoreVert from "~icons/material-symbols/more-vert";
interface Props {
info: Writable<FileInfo | null>;
onclick: () => void;
onOpenMenuClick: (metadata: FileInfo) => void;
}
let { info, onclick, onOpenMenuClick }: Props = $props();
const openMenu = (e: Event) => {
e.stopPropagation();
setTimeout(() => {
onOpenMenuClick($info!);
}, 100);
};
</script>
{#if $info}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div id="button" onclick={() => setTimeout(onclick, 100)} class="h-12 w-full rounded-xl">
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
<div class="flex-shrink-0 text-lg">
<IconDraft class="text-blue-400" />
</div>
<p title={$info.name} class="flex-grow truncate font-medium">
{$info.name}
</p>
<button
id="open-menu"
onclick={openMenu}
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
>
<IconMoreVert class="text-lg transition active:scale-95" />
</button>
</div>
</div>
{/if}
<style>
#button:active:not(:has(#open-menu:active)) {
@apply bg-gray-100;
}
#button-content:active:not(:has(#open-menu:active)) {
@apply scale-95;
}
</style>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { DirectoryInfo } from "$lib/stores";
import IconFolder from "~icons/material-symbols/folder";
import IconMoreVert from "~icons/material-symbols/more-vert";
type SubDirectoryInfo = DirectoryInfo & { id: number };
interface Props {
info: Writable<DirectoryInfo | null>;
onclick: () => void;
onOpenMenuClick: (metadata: SubDirectoryInfo) => void;
}
let { info, onclick, onOpenMenuClick }: Props = $props();
const openMenu = (e: Event) => {
e.stopPropagation();
setTimeout(() => {
onOpenMenuClick($info as SubDirectoryInfo);
}, 100);
};
</script>
{#if $info}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div id="button" onclick={() => setTimeout(onclick, 100)} class="h-12 w-full rounded-xl">
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
<div class="flex-shrink-0 text-lg">
<IconFolder />
</div>
<p title={$info.name} class="flex-grow truncate font-medium">
{$info.name}
</p>
<button
id="open-menu"
onclick={openMenu}
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
>
<IconMoreVert class="text-lg transition active:scale-95" />
</button>
</div>
</div>
{/if}
<style>
#button:active:not(:has(#open-menu:active)) {
@apply bg-gray-100;
}
#button-content:active:not(:has(#open-menu:active)) {
@apply scale-95;
}
</style>

View File

@@ -3,22 +3,17 @@ import {
encodeToBase64,
generateDataKey,
wrapDataKey,
unwrapDataKey,
encryptData,
encryptString,
decryptString,
} from "$lib/modules/crypto";
import type {
DirectoryRenameRequest,
DirectoryInfoResponse,
DirectoryCreateRequest,
FileRenameRequest,
FileUploadRequest,
} from "$lib/server/schemas";
import type { MasterKey } from "$lib/stores";
export { decryptFileMetadata } from "$lib/services/file";
export interface SelectedDirectoryEntry {
type: "directory" | "file";
id: number;
@@ -27,18 +22,6 @@ export interface SelectedDirectoryEntry {
name: string;
}
export const decryptDirectoryMetadata = async (
metadata: NonNullable<DirectoryInfoResponse["metadata"]>,
masterKey: CryptoKey,
) => {
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
return {
dataKey,
dataKeyVersion: metadata.dekVersion,
name: await decryptString(metadata.name, metadata.nameIv, dataKey),
};
};
export const requestDirectoryCreation = async (
name: string,
parentId: "root" | number,