diff --git a/src/lib/indexedDB/filesystem.ts b/src/lib/indexedDB/filesystem.ts index cf60b93..1cae833 100644 --- a/src/lib/indexedDB/filesystem.ts +++ b/src/lib/indexedDB/filesystem.ts @@ -62,6 +62,10 @@ export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => { await filesystem.directory.put(directoryInfo); }; +export const updateDirectoryInfo = async (id: number, changes: { name?: string }) => { + await filesystem.directory.update(id, changes); +}; + export const deleteDirectoryInfo = async (id: number) => { await filesystem.directory.delete(id); }; diff --git a/src/lib/modules/filesystem2.ts b/src/lib/modules/filesystem2.ts new file mode 100644 index 0000000..07ac748 --- /dev/null +++ b/src/lib/modules/filesystem2.ts @@ -0,0 +1,256 @@ +import { useQueryClient, createQuery, createMutation } from "@tanstack/svelte-query"; +import { browser } from "$app/environment"; +import { callGetApi, callPostApi } from "$lib/hooks"; +import { + getDirectoryInfos as getDirectoryInfosFromIndexedDB, + getDirectoryInfo as getDirectoryInfoFromIndexedDB, + storeDirectoryInfo, + updateDirectoryInfo, + deleteDirectoryInfo, + getFileInfos as getFileInfosFromIndexedDB, + getFileInfo as getFileInfoFromIndexedDB, + storeFileInfo, + deleteFileInfo, + getCategoryInfos as getCategoryInfosFromIndexedDB, + getCategoryInfo as getCategoryInfoFromIndexedDB, + storeCategoryInfo, + updateCategoryInfo as updateCategoryInfoInIndexedDB, + deleteCategoryInfo, + type DirectoryId, + type CategoryId, +} from "$lib/indexedDB"; +import { + generateDataKey, + wrapDataKey, + unwrapDataKey, + encryptString, + decryptString, +} from "$lib/modules/crypto"; +import type { DirectoryInfo } from "$lib/modules/filesystem"; +import type { + DirectoryCreateRequest, + DirectoryCreateResponse, + DirectoryInfoResponse, + DirectoryRenameRequest, +} from "$lib/server/schemas"; +import type { MasterKey } from "$lib/stores"; + +const initializedDirectoryIds = new Set(); +let temporaryIdCounter = -1; + +const getInitialDirectoryInfo = async (id: DirectoryId) => { + if (!browser || initializedDirectoryIds.has(id)) { + return undefined; + } else { + initializedDirectoryIds.add(id); + } + + 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") { + return { id, subDirectoryIds, fileIds }; + } else if (directory) { + return { id, name: directory.name, subDirectoryIds, fileIds }; + } + return undefined; +}; + +export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => { + const queryClient = useQueryClient(); + getInitialDirectoryInfo(id).then((info) => { + if (info && !queryClient.getQueryData(["directory", id])) { + queryClient.setQueryData(["directory", id], info); + } + }); // Intended + return createQuery({ + queryKey: ["directory", id], + queryFn: async () => { + const res = await callGetApi(`/api/directory/${id}`); // TODO: 404 + const { + metadata, + subDirectories: subDirectoryIds, + files: fileIds, + }: DirectoryInfoResponse = await res.json(); + + if (id === "root") { + return { id, subDirectoryIds, fileIds }; + } else { + const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey); + const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey); + await storeDirectoryInfo({ id, parentId: metadata!.parent, name }); + return { + id, + dataKey, + dataKeyVersion: new Date(metadata!.dekVersion), + name, + subDirectoryIds, + fileIds, + }; + } + }, + }); +}; + +export type DirectoryInfoStore = ReturnType; + +export const useDirectoryCreate = (parentId: DirectoryId) => { + const queryClient = useQueryClient(); + return createMutation< + { id: number; dataKey: CryptoKey; dataKeyVersion: Date }, + Error, + { name: string; masterKey: MasterKey }, + { prevParentInfo: DirectoryInfo | undefined; tempId: number } + >({ + mutationFn: async ({ name, masterKey }) => { + const { dataKey, dataKeyVersion } = await generateDataKey(); + const nameEncrypted = await encryptString(name, dataKey); + + const res = await callPostApi(`/api/directory/create`, { + parent: parentId, + mekVersion: masterKey.version, + dek: await wrapDataKey(dataKey, masterKey.key), + dekVersion: dataKeyVersion.toISOString(), + name: nameEncrypted.ciphertext, + nameIv: nameEncrypted.iv, + }); + const { directory: id }: DirectoryCreateResponse = await res.json(); + return { id, dataKey, dataKeyVersion }; + }, + onMutate: async ({ name }) => { + await queryClient.cancelQueries({ queryKey: ["directory", parentId] }); + + const prevParentInfo = queryClient.getQueryData(["directory", parentId]); + const tempId = temporaryIdCounter--; + if (prevParentInfo) { + queryClient.setQueryData(["directory", parentId], { + ...prevParentInfo, + subDirectoryIds: [...prevParentInfo.subDirectoryIds, tempId], + }); + queryClient.setQueryData(["directory", tempId], { + id: tempId, + name, + subDirectoryIds: [], + fileIds: [], + }); + } + + return { prevParentInfo, tempId }; + }, + onSuccess: async ({ id, dataKey, dataKeyVersion }, { name }) => { + queryClient.setQueryData(["directory", id], { + id, + name, + dataKey, + dataKeyVersion, + subDirectoryIds: [], + fileIds: [], + }); + await storeDirectoryInfo({ id, parentId, name }); + }, + onError: (error, { name }, context) => { + if (context?.prevParentInfo) { + queryClient.setQueryData(["directory", parentId], context.prevParentInfo); + } + console.error(`Failed to create directory "${name}" in parent ${parentId}:`, error); + }, + onSettled: (id) => { + queryClient.invalidateQueries({ queryKey: ["directory", parentId] }); + }, + }); +}; + +export const useDirectoryRename = () => { + const queryClient = useQueryClient(); + return createMutation< + void, + Error, + { + id: number; + dataKey: CryptoKey; + dataKeyVersion: Date; + newName: string; + }, + { prevInfo: (DirectoryInfo & { id: number }) | undefined } + >({ + mutationFn: async ({ id, dataKey, dataKeyVersion, newName }) => { + const newNameEncrypted = await encryptString(newName, dataKey); + await callPostApi(`/api/directory/${id}/rename`, { + dekVersion: dataKeyVersion.toISOString(), + name: newNameEncrypted.ciphertext, + nameIv: newNameEncrypted.iv, + }); + }, + onMutate: async ({ id, newName }) => { + await queryClient.cancelQueries({ queryKey: ["directory", id] }); + + const prevInfo = queryClient.getQueryData(["directory", id]); + if (prevInfo) { + queryClient.setQueryData(["directory", id], { + ...prevInfo, + name: newName, + }); + await updateDirectoryInfo(id, { name: newName }); + } + + return { prevInfo }; + }, + onSuccess: async (data, { id, newName }) => { + await updateDirectoryInfo(id, { name: newName }); + }, + onError: (error, { id }, context) => { + if (context?.prevInfo) { + queryClient.setQueryData(["directory", id], context.prevInfo); + } + console.error("Failed to rename directory:", error); + }, + onSettled: (data, error, { id }) => { + queryClient.invalidateQueries({ queryKey: ["directory", id] }); + }, + }); +}; + +export const useDirectoryDelete = (parentId: DirectoryId) => { + const queryClient = useQueryClient(); + return createMutation< + void, + Error, + { id: number }, + { prevInfo: (DirectoryInfo & { id: number }) | undefined } + >({ + mutationFn: async ({ id }) => { + await callPostApi(`/api/directory/${id}/delete`); + }, + onMutate: async ({ id }) => { + await queryClient.cancelQueries({ queryKey: ["directory", parentId] }); + + const prevParentInfo = queryClient.getQueryData(["directory", parentId]); + if (prevParentInfo) { + queryClient.setQueryData(["directory", parentId], { + ...prevParentInfo, + subDirectoryIds: prevParentInfo.subDirectoryIds.filter((subId) => subId !== id), + }); + } + + const prevInfo = queryClient.getQueryData(["directory", id]); + return { prevInfo }; + }, + onSuccess: async (data, { id }) => { + await deleteDirectoryInfo(id); + }, + onError: (error, { id }, context) => { + if (context?.prevInfo) { + queryClient.setQueryData(["directory", parentId], context?.prevInfo); + } + console.error("Failed to delete directory:", error); + }, + onSettled: (data, error, { id }) => { + queryClient.invalidateQueries({ queryKey: ["directory", parentId] }); + }, + }); +}; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index c3169fc..e8ecaa5 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -39,7 +39,7 @@ interface File { export type NewFile = Omit; export const registerDirectory = async (params: NewDirectory) => { - await db.transaction().execute(async (trx) => { + return await db.transaction().execute(async (trx) => { const mek = await trx .selectFrom("master_encryption_key") .select("version") @@ -73,6 +73,7 @@ export const registerDirectory = async (params: NewDirectory) => { new_name: params.encName, }) .execute(); + return { id: directoryId }; }); }; diff --git a/src/lib/server/schemas/directory.ts b/src/lib/server/schemas/directory.ts index ffd13bc..cc58c12 100644 --- a/src/lib/server/schemas/directory.ts +++ b/src/lib/server/schemas/directory.ts @@ -39,3 +39,8 @@ export const directoryCreateRequest = z.object({ nameIv: z.string().base64().nonempty(), }); export type DirectoryCreateRequest = z.input; + +export const directoryCreateResponse = z.object({ + directory: z.number().int().positive(), +}); +export type DirectoryCreateResponse = z.output; diff --git a/src/lib/server/services/directory.ts b/src/lib/server/services/directory.ts index fdab587..9403f4d 100644 --- a/src/lib/server/services/directory.ts +++ b/src/lib/server/services/directory.ts @@ -86,7 +86,8 @@ export const createDirectory = async (params: NewDirectory) => { } try { - await registerDirectory(params); + const { id } = await registerDirectory(params); + return { id }; } catch (e) { if (e instanceof IntegrityError && e.message === "Inactive MEK version") { error(400, "Invalid MEK version"); diff --git a/src/routes/(main)/directory/[[id]]/+page.svelte b/src/routes/(main)/directory/[[id]]/+page.svelte index 98572b3..25679e8 100644 --- a/src/routes/(main)/directory/[[id]]/+page.svelte +++ b/src/routes/(main)/directory/[[id]]/+page.svelte @@ -4,7 +4,13 @@ import { goto } from "$app/navigation"; import { FloatingButton } from "$lib/components/atoms"; import { TopBar } from "$lib/components/molecules"; - import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem"; + import { type DirectoryInfo } from "$lib/modules/filesystem"; + import { + getDirectoryInfo, + useDirectoryCreate, + useDirectoryRename, + useDirectoryDelete, + } from "$lib/modules/filesystem2"; import { masterKeyStore, hmacSecretStore } from "$lib/stores"; import DirectoryCreateModal from "./DirectoryCreateModal.svelte"; import DirectoryEntries from "./DirectoryEntries"; @@ -18,7 +24,6 @@ import { createContext, requestHmacSecretDownload, - requestDirectoryCreation, requestFileUpload, requestEntryRename, requestEntryDeletion, @@ -29,7 +34,11 @@ let { data } = $props(); let context = createContext(); - let info: Writable | undefined = $state(); + let info = $derived(getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!)); + let requestDirectoryCreation = $derived(useDirectoryCreate(data.id)); + let requestDirectoryRename = useDirectoryRename(); + let requestDirectoryDeletion = $derived(useDirectoryDelete(data.id)); + let fileInput: HTMLInputElement | undefined = $state(); let duplicatedFile: File | undefined = $state(); let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state(); @@ -57,7 +66,7 @@ .then((res) => { if (!res) return; // TODO: FIXME - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); + // info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); }) .catch((e: Error) => { // TODO: FIXME @@ -73,10 +82,6 @@ throw new Error("Failed to download hmac secrets"); } }); - - $effect(() => { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); - }); @@ -87,9 +92,9 @@
{#if data.id !== "root"} - + {/if} - {#if $info} + {#if $info.status === "success"}
goto("/file/uploads")} /> @@ -97,7 +102,7 @@
{#key $info} goto(`/${type}/${id}`)} onEntryMenuClick={(entry) => { context.selectedEntry = entry; @@ -130,11 +135,11 @@ { - if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; + $requestDirectoryCreation.mutate({ + name, + masterKey: $masterKeyStore?.get(1)!, + }); + return true; // TODO }} /> { - if (await requestEntryRename(context.selectedEntry!, newName)) { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; + if (context.selectedEntry!.type === "directory") { + $requestDirectoryRename.mutate({ + id: context.selectedEntry!.id, + dataKey: context.selectedEntry!.dataKey, + dataKeyVersion: context.selectedEntry!.dataKeyVersion, + newName, + }); + return true; // TODO + } else { + if (await requestEntryRename(context.selectedEntry!, newName)) { + // info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; } - return false; }} /> { - if (await requestEntryDeletion(context.selectedEntry!)) { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; + if (context.selectedEntry!.type === "directory") { + $requestDirectoryDeletion.mutate({ + id: context.selectedEntry!.id, + }); + return true; // TODO + } else { + if (await requestEntryDeletion(context.selectedEntry!)) { + // info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; } - return false; }} /> diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte index 7ad3af7..454799d 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte @@ -1,12 +1,8 @@