파일 및 디렉터리 목록을 IndexedDB에 캐싱하도록 구현

This commit is contained in:
static
2025-01-17 12:22:51 +09:00
parent 7e711c1b8f
commit 7aa6ba0eab
23 changed files with 285 additions and 165 deletions

View File

@@ -0,0 +1,52 @@
import { Dexie, type EntityTable } from "dexie";
export type DirectoryId = "root" | number;
interface DirectoryInfo {
id: number;
parentId: DirectoryId;
name: string;
}
interface FileInfo {
id: number;
parentId: DirectoryId;
name: string;
contentType: string;
createdAt?: Date;
lastModifiedAt: Date;
}
const filesystem = new Dexie("filesystem") as Dexie & {
directory: EntityTable<DirectoryInfo, "id">;
file: EntityTable<FileInfo, "id">;
};
filesystem.version(1).stores({
directory: "id, parentId",
file: "id, parentId",
});
export const getDirectoryInfos = async (parentId: DirectoryId) => {
return await filesystem.directory.where({ parentId }).toArray();
};
export const getDirectoryInfo = async (id: number) => {
return await filesystem.directory.get(id);
};
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
await filesystem.directory.put(directoryInfo);
};
export const getFileInfos = async (parentId: DirectoryId) => {
return await filesystem.file.where({ parentId }).toArray();
};
export const getFileInfo = async (id: number) => {
return await filesystem.file.get(id);
};
export const storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.put(fileInfo);
};

View File

@@ -1,2 +1,3 @@
export * from "./cacheIndex";
export * from "./filesystem";
export * from "./keyStore";

View File

@@ -1,3 +1,2 @@
export * from "./cache";
export * from "./info";
export * from "./upload";

View File

@@ -1,104 +0,0 @@
import { writable, type 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,
infoStore: Writable<DirectoryInfo | null>,
) => {
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: new Date(metadata!.dekVersion),
name: await decryptString(metadata!.name, metadata!.nameIv, dataKey),
subDirectoryIds: subDirectories,
fileIds: files,
};
}
infoStore.update(() => 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, info);
return info;
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
const fetchFileInfo = async (
fileId: number,
masterKey: CryptoKey,
infoStore: Writable<FileInfo | null>,
) => {
const res = await callGetApi(`/api/file/${fileId}`);
if (!res.ok) {
if (res.status === 404) {
infoStore.update(() => null);
return;
}
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: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name: await decryptString(metadata.name, metadata.nameIv, dataKey),
createdAt:
metadata.createdAt && metadata.createdAtIv
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
: undefined,
lastModifiedAt: await decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
};
infoStore.update(() => 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, info);
return info;
};

View File

@@ -191,7 +191,7 @@ export const uploadFile = async (
form.set(
"metadata",
JSON.stringify({
parentId,
parent: parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion.toISOString(),

View File

@@ -0,0 +1,192 @@
import { get, writable, type Writable } from "svelte/store";
import { callGetApi } from "$lib/hooks";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
type DirectoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
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;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
}
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const fetchDirectoryInfoFromIndexedDB = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
) => {
if (get(info)) return;
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
const subDirectoryIds = subDirectories.map(({ id }) => id);
const fileIds = files.map(({ id }) => id);
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
if (!directory) return;
info.set({ id, name: directory.name, subDirectoryIds, fileIds });
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
const res = await callGetApi(`/api/directory/${id}`);
if (!res.ok) throw new Error("Failed to fetch directory information"); // TODO: Handle 404
const {
metadata,
subDirectories: subDirectoryIds,
files: fileIds,
}: DirectoryInfoResponse = await res.json();
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
info.set({
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
});
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
}
};
const fetchDirectoryInfo = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
await fetchDirectoryInfoFromIndexedDB(id, info);
await fetchDirectoryInfoFromServer(id, info, masterKey);
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = directoryInfoStore.get(id);
if (!info) {
info = writable(null);
directoryInfoStore.set(id, info);
}
fetchDirectoryInfo(id, info, masterKey);
return info;
};
const fetchFileInfoFromIndexedDB = async (id: number, info: Writable<FileInfo | null>) => {
if (get(info)) return;
const file = await getFileInfoFromIndexedDB(id);
if (!file) return;
info.set(file);
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
const fetchFileInfoFromServer = async (
id: number,
info: Writable<FileInfo | null>,
masterKey: CryptoKey,
) => {
const res = await callGetApi(`/api/file/${id}`);
if (!res.ok) throw new Error("Failed to fetch file information"); // TODO: Handle 404
const metadata: FileInfoResponse = await res.json();
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
const createdAt =
metadata.createdAt && metadata.createdAtIv
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
: undefined;
const lastModifiedAt = await decryptDate(
metadata.lastModifiedAt,
metadata.lastModifiedAtIv,
dataKey,
);
info.set({
id,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
});
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
});
};
const fetchFileInfo = async (id: number, info: Writable<FileInfo | null>, masterKey: CryptoKey) => {
await fetchFileInfoFromIndexedDB(id, info);
await fetchFileInfoFromServer(id, info, masterKey);
};
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, info, masterKey);
return info;
};

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
export const directoryInfoResponse = z.object({
metadata: z
.object({
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
@@ -28,7 +29,7 @@ export const directoryRenameRequest = z.object({
export type DirectoryRenameRequest = z.infer<typeof directoryRenameRequest>;
export const directoryCreateRequest = z.object({
parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),

View File

@@ -2,6 +2,7 @@ import mime from "mime";
import { z } from "zod";
export const fileInfoResponse = z.object({
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
@@ -38,7 +39,7 @@ export const duplicateFileScanResponse = z.object({
export type DuplicateFileScanResponse = z.infer<typeof duplicateFileScanResponse>;
export const fileUploadRequest = z.object({
parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),

View File

@@ -19,9 +19,9 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
const directories = await getAllDirectoriesByParent(userId, directoryId);
const files = await getAllFilesByParent(userId, directoryId);
return {
metadata: directory && {
parentId: directory.parentId ?? ("root" as const),
mekVersion: directory.mekVersion,
encDek: directory.encDek,
dekVersion: directory.dekVersion,

View File

@@ -23,6 +23,7 @@ export const getFileInformation = async (userId: number, fileId: number) => {
}
return {
parentId: file.parentId ?? ("root" as const),
mekVersion: file.mekVersion,
encDek: file.encDek,
dekVersion: file.dekVersion,

View File

@@ -1,34 +1,4 @@
import { writable, 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;
contentType: string;
contentIv: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
}
export interface FileUploadStatus {
name: string;
parentId: "root" | number;
@@ -45,8 +15,4 @@ export interface FileUploadStatus {
estimated?: number;
}
export const directoryInfoStore = new Map<"root" | number, Writable<DirectoryInfo | null>>();
export const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
export const fileUploadStatusStore = writable<Writable<FileUploadStatus>[]>([]);

View File

@@ -3,8 +3,8 @@
import { untrack } from "svelte";
import type { Writable } from "svelte/store";
import { TopBar } from "$lib/components";
import { getFileInfo } from "$lib/modules/file";
import { masterKeyStore, type FileInfo } from "$lib/stores";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { requestFileDownload } from "./service";
type ContentType = "image" | "video";
@@ -27,7 +27,7 @@
});
$effect(() => {
if ($info && !isDownloaded) {
if ($info?.contentIv && $info?.dataKey && !isDownloaded) {
untrack(() => {
isDownloaded = true;
@@ -37,7 +37,7 @@
contentType = "video";
}
requestFileDownload(data.id, $info.contentIv, $info.dataKey).then(async (res) => {
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (res) => {
content = new Blob([res], { type: $info.contentType });
if (content.type === "image/heic" || content.type === "image/heif") {
const { default: heic2any } = await import("heic2any");

View File

@@ -3,8 +3,9 @@
import type { Writable } from "svelte/store";
import { TopBar } from "$lib/components";
import type { FileCacheIndex } from "$lib/indexedDB";
import { getFileCacheIndex, getFileInfo } from "$lib/modules/file";
import { masterKeyStore, type FileInfo } from "$lib/stores";
import { getFileCacheIndex } from "$lib/modules/file";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
import { formatFileSize, deleteFileCache as doDeleteFileCache } from "./service";

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileCacheIndex } from "$lib/indexedDB";
import type { FileInfo } from "$lib/stores";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatFileSize } from "./service";
import IconDraft from "~icons/material-symbols/draft";

View File

@@ -4,8 +4,8 @@
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components";
import { FloatingButton } from "$lib/components/buttons";
import { getDirectoryInfo } from "$lib/modules/file";
import { masterKeyStore, hmacSecretStore, type DirectoryInfo } from "$lib/stores";
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import CreateBottomSheet from "./CreateBottomSheet.svelte";
import CreateDirectoryModal from "./CreateDirectoryModal.svelte";
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";

View File

@@ -1,14 +1,13 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { getDirectoryInfo, getFileInfo } from "$lib/modules/file";
import {
fileUploadStatusStore,
masterKeyStore,
getDirectoryInfo,
getFileInfo,
type DirectoryInfo,
type FileInfo,
type FileUploadStatus,
} from "$lib/stores";
} from "$lib/modules/filesystem";
import { fileUploadStatusStore, masterKeyStore, type FileUploadStatus } from "$lib/stores";
import File from "./File.svelte";
import SubDirectory from "./SubDirectory.svelte";
import { SortBy, sortEntries } from "./service";
@@ -110,7 +109,7 @@
</script>
{#if subDirectories.length + files.length > 0}
<div class="pb-[4.5rem]">
<div class="space-y-1 pb-[4.5rem]">
{#each subDirectories as { info }}
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{/each}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileInfo } from "$lib/stores";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "./service";
import type { SelectedDirectoryEntry } from "../service";
@@ -17,6 +17,8 @@
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info!;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
setTimeout(() => {
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
}, 100);
@@ -26,6 +28,8 @@
e.stopPropagation();
const { id, dataKey, dataKeyVersion, name } = $info!;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
setTimeout(() => {
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
}, 100);

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { DirectoryInfo } from "$lib/stores";
import type { DirectoryInfo } from "$lib/modules/filesystem";
import type { SelectedDirectoryEntry } from "../service";
import IconFolder from "~icons/material-symbols/folder";
@@ -18,6 +18,8 @@
const openDirectory = () => {
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
setTimeout(() => {
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
}, 100);
@@ -27,6 +29,8 @@
e.stopPropagation();
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
setTimeout(() => {
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
}, 100);

View File

@@ -47,7 +47,7 @@ export const requestDirectoryCreation = async (
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
await callPostApi<DirectoryCreateRequest>("/api/directory/create", {
parentId,
parent: parentId,
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion.toISOString(),

View File

@@ -20,6 +20,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
return json(
directoryInfoResponse.parse({
metadata: metadata && {
parent: metadata.parentId,
mekVersion: metadata.mekVersion,
dek: metadata.encDek,
dekVersion: metadata.dekVersion.toISOString(),

View File

@@ -9,11 +9,11 @@ export const POST: RequestHandler = async ({ locals, request }) => {
const zodRes = directoryCreateRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { parentId, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
const { parent, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
await createDirectory({
userId,
parentId,
parentId: parent,
mekVersion,
encDek: dek,
dekVersion: new Date(dekVersion),

View File

@@ -17,6 +17,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
const { id } = zodRes.data;
const {
parentId,
mekVersion,
encDek,
dekVersion,
@@ -28,6 +29,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
} = await getFileInformation(userId, id);
return json(
fileInfoResponse.parse({
parent: parentId,
mekVersion,
dek: encDek,
dekVersion: dekVersion.toISOString(),

View File

@@ -12,7 +12,7 @@ const parseFileMetadata = (userId: number, json: string) => {
const zodRes = fileUploadRequest.safeParse(JSON.parse(json));
if (!zodRes.success) error(400, "Invalid request body");
const {
parentId,
parent,
mekVersion,
dek,
dekVersion,
@@ -32,7 +32,7 @@ const parseFileMetadata = (userId: number, json: string) => {
return {
userId,
parentId,
parentId: parent,
mekVersion,
encDek: dek,
dekVersion: new Date(dekVersion),