파일 관련 API 요청을 TanStack Query로 마이그레이션

This commit is contained in:
static
2025-07-17 13:09:02 +09:00
parent e10b600293
commit 27fcb7472e
18 changed files with 399 additions and 240 deletions

View File

@@ -3,7 +3,8 @@
import { get, type Writable } from "svelte/store";
import { CheckBox } from "$lib/components/atoms";
import { SubCategories, type SelectedCategory } from "$lib/components/molecules";
import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
import type { CategoryInfo } from "$lib/modules/filesystem";
import { getFileInfo, type FileInfoStore } from "$lib/modules/filesystem2";
import { SortBy, sortEntries } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
@@ -33,9 +34,7 @@
isFileRecursive = $bindable(),
}: Props = $props();
let files: { name?: string; info: Writable<FileInfo | null>; isRecursive: boolean }[] = $state(
[],
);
let files: { name?: string; info: FileInfoStore; isRecursive: boolean }[] = $state([]);
$effect(() => {
files =
@@ -44,7 +43,7 @@
.map(({ id, isRecursive }) => {
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
return {
name: get(info)?.name,
name: get(info).data?.name,
info,
isRecursive,
};
@@ -58,8 +57,8 @@
const unsubscribes = files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
if (file.name === value.data?.name) return;
file.name = value.data?.name;
sort();
}),
);

View File

@@ -2,13 +2,13 @@
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem";
import type { FileInfo, FileInfoStore } from "$lib/modules/filesystem2";
import { requestFileThumbnailDownload, type SelectedFile } from "./service";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: Writable<FileInfo | null>;
info: FileInfoStore;
onclick: (selectedFile: SelectedFile) => void;
onRemoveClick?: (selectedFile: SelectedFile) => void;
}
@@ -18,22 +18,22 @@
let thumbnail: string | undefined = $state();
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
const { id, dataKey, dataKeyVersion, name } = $info.data as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ id, dataKey, dataKeyVersion, name });
};
const removeFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
const { id, dataKey, dataKeyVersion, name } = $info.data as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if ($info?.dataKey) {
requestFileThumbnailDownload($info.id, $info.dataKey)
if ($info.data?.dataKey) {
requestFileThumbnailDownload($info.data.id, $info.data.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
@@ -47,13 +47,13 @@
});
</script>
{#if $info}
{#if $info.status === "success"}
<ActionEntryButton
class="h-12"
onclick={openFile}
actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={removeFile}
>
<DirectoryEntryLabel type="file" {thumbnail} name={$info.name} />
<DirectoryEntryLabel type="file" {thumbnail} name={$info.data.name} />
</ActionEntryButton>
{/if}

View File

@@ -86,6 +86,10 @@ export const storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.put(fileInfo);
};
export const updateFileInfo = async (id: number, changes: { name?: string }) => {
await filesystem.file.update(id, changes);
};
export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id);
};

View File

@@ -5,6 +5,7 @@ import { writable, type Writable } from "svelte/store";
import {
encodeToBase64,
generateDataKey,
makeAESKeyNonextractable,
wrapDataKey,
encryptData,
encryptString,
@@ -118,12 +119,14 @@ const encryptFile = limitFunction(
});
return {
dataKey: await makeAESKeyNonextractable(dataKey),
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAt,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail: thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted },
@@ -176,9 +179,7 @@ export const uploadFile = async (
hmacSecret: HmacSecret,
masterKey: MasterKey,
onDuplicate: () => Promise<boolean>,
): Promise<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
> => {
) => {
const status = writable<FileUploadStatus>({
name: file.name,
parentId,
@@ -208,12 +209,14 @@ export const uploadFile = async (
}
const {
dataKey,
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAt,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail,
@@ -256,7 +259,16 @@ export const uploadFile = async (
}
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
return {
fileId,
fileDataKey: dataKey,
fileDataKeyVersion: dataKeyVersion,
fileType,
fileEncryptedIv: fileEncrypted.iv,
fileCreatedAt: createdAt,
fileBuffer,
thumbnailBuffer: thumbnail?.plaintext,
};
} catch (e) {
status.update((value) => {
value.status = "error";

View File

@@ -1,9 +1,6 @@
import { get, writable, type Writable } from "svelte/store";
import { callGetApi } from "$lib/hooks";
import {
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
@@ -12,23 +9,7 @@ import {
type CategoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import type {
CategoryInfoResponse,
CategoryFileListResponse,
FileInfoResponse,
} from "$lib/server/schemas";
export interface FileInfo {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
import type { CategoryInfoResponse, CategoryFileListResponse } from "$lib/server/schemas";
export type CategoryInfo =
| {
@@ -50,90 +31,8 @@ export type CategoryInfo =
isFileRecursive: boolean;
};
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const categoryInfoStore = new Map<CategoryId, Writable<CategoryInfo | null>>();
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.status === 404) {
info.set(null);
await deleteFileInfo(id);
return;
} else 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 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,
categoryIds: metadata.categories,
});
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
};
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); // Intended
return info;
};
const fetchCategoryInfoFromIndexedDB = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,

View File

@@ -8,16 +8,8 @@ import {
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,
@@ -53,14 +45,14 @@ export type DirectoryInfo =
fileIds: number[];
};
const initializedDirectoryIds = new Set<DirectoryId>();
const initializedIds = new Set<DirectoryId>();
let temporaryIdCounter = -1;
const getInitialDirectoryInfo = async (id: DirectoryId) => {
if (!browser || initializedDirectoryIds.has(id)) {
if (!browser || initializedIds.has(id)) {
return undefined;
} else {
initializedDirectoryIds.add(id);
initializedIds.add(id);
}
const [directory, subDirectories, files] = await Promise.all([
@@ -254,7 +246,9 @@ export const useDirectoryDeletion = (parentId: DirectoryId) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: prevParentInfo.subDirectoryIds.filter((subId) => subId !== id),
subDirectoryIds: prevParentInfo.subDirectoryIds.filter(
(subDirectoryId) => subDirectoryId !== id,
),
};
});
return {};

View File

@@ -0,0 +1,238 @@
import { useQueryClient, createQuery, createMutation } from "@tanstack/svelte-query";
import { browser } from "$app/environment";
import { callGetApi, callPostApi } from "$lib/hooks";
import {
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
updateFileInfo,
deleteFileInfo,
type DirectoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, encryptString, decryptString } from "$lib/modules/crypto";
import { uploadFile } from "$lib/modules/file";
import type { FileInfoResponse, FileRenameRequest } from "$lib/server/schemas";
import type { MasterKey, HmacSecret } from "$lib/stores";
import type { DirectoryInfo } from "./directory";
export interface FileInfo {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
const initializedFileIds = new Set<number>();
const getInitialFileInfo = async (id: number) => {
if (!browser || initializedFileIds.has(id)) {
return undefined;
}
initializedFileIds.add(id);
return await getFileInfoFromIndexedDB(id);
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
export const getFileInfo = (id: number, masterKey: CryptoKey) => {
const queryClient = useQueryClient();
getInitialFileInfo(id).then((info) => {
if (info && !queryClient.getQueryData(["file", id])) {
queryClient.setQueryData<FileInfo>(["file", id], info);
}
}); // Intended
return createQuery<FileInfo>({
queryKey: ["file", id],
queryFn: async () => {
const res = await callGetApi(`/api/file/${id}`); // TODO: 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,
);
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
return {
id,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
};
},
});
};
export type FileInfoStore = ReturnType<typeof getFileInfo>;
export const useFileUpload = (
parentId: DirectoryId,
masterKey: MasterKey,
hmacSecret: HmacSecret,
) => {
const queryClient = useQueryClient();
return createMutation<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer },
Error,
{ file: File; onDuplicate: () => Promise<boolean> },
{ tempId: number }
>({
mutationFn: async ({ file, onDuplicate }) => {
const res = await uploadFile(file, parentId, hmacSecret, masterKey, onDuplicate);
if (!res) throw new Error("Failed to upload file");
queryClient.setQueryData<FileInfo>(["file", res.fileId], {
id: res.fileId,
dataKey: res.fileDataKey,
dataKeyVersion: res.fileDataKeyVersion,
contentType: res.fileType,
contentIv: res.fileEncryptedIv,
name: file.name,
createdAt: res.fileCreatedAt,
lastModifiedAt: new Date(file.lastModified),
categoryIds: [],
});
await storeFileInfo({
id: res.fileId,
parentId,
name: file.name,
contentType: res.fileType,
createdAt: res.fileCreatedAt,
lastModifiedAt: new Date(file.lastModified),
categoryIds: [],
});
return {
fileId: res.fileId,
fileBuffer: res.fileBuffer,
thumbnailBuffer: res.thumbnailBuffer,
};
},
onSuccess: async ({ fileId }) => {
await queryClient.cancelQueries({ queryKey: ["directory", parentId] });
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
fileIds: [...prevParentInfo.fileIds, fileId],
};
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["directory", parentId] });
},
});
};
export const useFileRename = () => {
const queryClient = useQueryClient();
return createMutation<
void,
Error,
{
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
newName: string;
},
{ oldName: string | undefined }
>({
mutationFn: async ({ id, dataKey, dataKeyVersion, newName }) => {
const newNameEncrypted = await encryptString(newName, dataKey);
const res = await callPostApi<FileRenameRequest>(`/api/file/${id}/rename`, {
dekVersion: dataKeyVersion.toISOString(),
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
if (!res.ok) throw new Error("Failed to rename file");
await updateFileInfo(id, { name: newName });
},
onMutate: async ({ id, newName }) => {
await queryClient.cancelQueries({ queryKey: ["file", id] });
const prevInfo = queryClient.getQueryData<FileInfo>(["file", id]);
if (prevInfo) {
queryClient.setQueryData<FileInfo>(["file", id], {
...prevInfo,
name: newName,
});
}
return { oldName: prevInfo?.name };
},
onError: (_error, { id }, context) => {
if (context?.oldName) {
queryClient.setQueryData<FileInfo>(["file", id], (prevInfo) => {
if (!prevInfo) return undefined;
return { ...prevInfo, name: context.oldName! };
});
}
},
onSettled: (_data, _error, { id }) => {
queryClient.invalidateQueries({ queryKey: ["file", id] });
},
});
};
export const useFileDeletion = (parentId: DirectoryId) => {
const queryClient = useQueryClient();
return createMutation<void, Error, { id: number }, {}>({
mutationFn: async ({ id }) => {
const res = await callPostApi(`/api/file/${id}/delete`);
if (!res.ok) throw new Error("Failed to delete file");
await deleteFileInfo(id);
},
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ["directory", parentId] });
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
fileIds: prevParentInfo.fileIds.filter((fileId) => fileId !== id),
};
});
return {};
},
onError: (_error, { id }, context) => {
if (context) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
fileIds: [...prevParentInfo.fileIds, id],
};
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["directory", parentId] });
},
});
};

View File

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