diff --git a/src/lib/components/atoms/RowVirtualizer.svelte b/src/lib/components/atoms/RowVirtualizer.svelte index e821c5f..444b9c7 100644 --- a/src/lib/components/atoms/RowVirtualizer.svelte +++ b/src/lib/components/atoms/RowVirtualizer.svelte @@ -1,6 +1,6 @@ -
+
{#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)}
diff --git a/src/lib/components/atoms/buttons/FileThumbnailButton.svelte b/src/lib/components/atoms/buttons/FileThumbnailButton.svelte index c18101c..bb7761e 100644 --- a/src/lib/components/atoms/buttons/FileThumbnailButton.svelte +++ b/src/lib/components/atoms/buttons/FileThumbnailButton.svelte @@ -1,42 +1,34 @@ -{#if $info} - -{/if} + {/await} + diff --git a/src/lib/components/molecules/Categories.svelte b/src/lib/components/molecules/Categories.svelte new file mode 100644 index 0000000..b8d52bb --- /dev/null +++ b/src/lib/components/molecules/Categories.svelte @@ -0,0 +1,44 @@ + + + + +{#if categoriesWithName.length > 0} +
+ {#each categoriesWithName as category (category.id)} + onCategoryClick(category)} + actionButtonIcon={categoryMenuIcon} + onActionButtonClick={() => onCategoryMenuClick?.(category)} + > + + + {/each} +
+{/if} diff --git a/src/lib/components/molecules/Categories/Categories.svelte b/src/lib/components/molecules/Categories/Categories.svelte deleted file mode 100644 index 54368c6..0000000 --- a/src/lib/components/molecules/Categories/Categories.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - -{#if categoriesWithName.length > 0} -
- {#each categoriesWithName as { info }} - - {/each} -
-{/if} diff --git a/src/lib/components/molecules/Categories/Category.svelte b/src/lib/components/molecules/Categories/Category.svelte deleted file mode 100644 index aab227c..0000000 --- a/src/lib/components/molecules/Categories/Category.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - -{#if $info} - - - -{/if} diff --git a/src/lib/components/molecules/Categories/index.ts b/src/lib/components/molecules/Categories/index.ts deleted file mode 100644 index d8a70c2..0000000 --- a/src/lib/components/molecules/Categories/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./Categories.svelte"; -export * from "./service"; diff --git a/src/lib/components/molecules/Categories/service.ts b/src/lib/components/molecules/Categories/service.ts deleted file mode 100644 index 08c41db..0000000 --- a/src/lib/components/molecules/Categories/service.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface SelectedCategory { - id: number; - dataKey: CryptoKey; - dataKeyVersion: Date; - name: string; -} diff --git a/src/lib/components/molecules/SubCategories.svelte b/src/lib/components/molecules/SubCategories.svelte index 9c84a89..6db38f1 100644 --- a/src/lib/components/molecules/SubCategories.svelte +++ b/src/lib/components/molecules/SubCategories.svelte @@ -1,10 +1,8 @@
@@ -53,14 +43,12 @@ {#if subCategoryCreatePosition === "top"} {@render subCategoryCreate()} {/if} - {#key info} - - {/key} + {#if subCategoryCreatePosition === "bottom"} {@render subCategoryCreate()} {/if} diff --git a/src/lib/components/molecules/index.ts b/src/lib/components/molecules/index.ts index 8edc84a..a36afcd 100644 --- a/src/lib/components/molecules/index.ts +++ b/src/lib/components/molecules/index.ts @@ -1,7 +1,7 @@ export * from "./ActionModal.svelte"; export { default as ActionModal } from "./ActionModal.svelte"; -export * from "./Categories"; -export { default as Categories } from "./Categories"; +export * from "./Categories.svelte"; +export { default as Categories } from "./Categories.svelte"; export { default as IconEntryButton } from "./IconEntryButton.svelte"; export * from "./labels"; export { default as SubCategories } from "./SubCategories.svelte"; diff --git a/src/lib/components/organisms/Category/Category.svelte b/src/lib/components/organisms/Category/Category.svelte index b42aeef..6482a16 100644 --- a/src/lib/components/organisms/Category/Category.svelte +++ b/src/lib/components/organisms/Category/Category.svelte @@ -1,11 +1,9 @@ @@ -89,26 +75,19 @@

하위 카테고리의 파일

- {#key info} - 48 + (index + 1 < files.length ? 4 : 0)} - > - {#snippet item(index)} - {@const { info, isRecursive } = files[index]!} -
- -
- {/snippet} - {#snippet placeholder()} -

이 카테고리에 추가된 파일이 없어요.

- {/snippet} -
- {/key} + 48} itemGap={4}> + {#snippet item(index)} + {@const { details } = files[index]!} + + {/snippet} + {#snippet placeholder()} +

이 카테고리에 추가된 파일이 없어요.

+ {/snippet} +
{/if}
diff --git a/src/lib/components/organisms/Category/File.svelte b/src/lib/components/organisms/Category/File.svelte index 8e3fc12..d4f5d4d 100644 --- a/src/lib/components/organisms/Category/File.svelte +++ b/src/lib/components/organisms/Category/File.svelte @@ -1,59 +1,38 @@ -{#if $info} - - - -{/if} + onclick(info)} + actionButtonIcon={onRemoveClick && IconClose} + onActionButtonClick={() => onRemoveClick?.(info)} +> + {#await thumbnailPromise} + + {:then thumbnail} + + {/await} + diff --git a/src/lib/components/organisms/Category/service.ts b/src/lib/components/organisms/Category/service.ts index fb6e640..3c78d2f 100644 --- a/src/lib/components/organisms/Category/service.ts +++ b/src/lib/components/organisms/Category/service.ts @@ -1,8 +1,4 @@ -export { requestFileThumbnailDownload } from "$lib/services/file"; - export interface SelectedFile { id: number; - dataKey: CryptoKey; - dataKeyVersion: Date; name: string; } diff --git a/src/lib/components/organisms/Gallery.svelte b/src/lib/components/organisms/Gallery.svelte index 1fcb4ff..dc9f157 100644 --- a/src/lib/components/organisms/Gallery.svelte +++ b/src/lib/components/organisms/Gallery.svelte @@ -1,98 +1,48 @@ @@ -101,8 +51,8 @@ itemHeight={(index) => rows[index]!.type === "header" ? 28 - : Math.ceil(rows[index]!.items.length / 4) * 181 + - (Math.ceil(rows[index]!.items.length / 4) - 1) * 4 + + : Math.ceil(rows[index]!.files.length / 4) * 181 + + (Math.ceil(rows[index]!.files.length / 4) - 1) * 4 + 16} class="flex flex-grow flex-col" > @@ -112,8 +62,8 @@

{row.label}

{:else}
- {#each row.items as { info }} - + {#each row.files as file (file.id)} + {/each}
{/if} @@ -123,8 +73,6 @@

{#if files.length === 0} 업로드된 파일이 없어요. - {:else if filesWithDate.length === 0} - 파일 목록을 불러오고 있어요. {:else} 사진 또는 동영상이 없어요. {/if} diff --git a/src/lib/indexedDB/filesystem.ts b/src/lib/indexedDB/filesystem.ts index cf60b93..7be44c7 100644 --- a/src/lib/indexedDB/filesystem.ts +++ b/src/lib/indexedDB/filesystem.ts @@ -1,7 +1,5 @@ import { Dexie, type EntityTable } from "dexie"; -export type DirectoryId = "root" | number; - interface DirectoryInfo { id: number; parentId: DirectoryId; @@ -18,8 +16,6 @@ interface FileInfo { categoryIds: number[]; } -export type CategoryId = "root" | number; - interface CategoryInfo { id: number; parentId: CategoryId; @@ -78,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/file/download.svelte.ts b/src/lib/modules/file/download.svelte.ts new file mode 100644 index 0000000..bea8316 --- /dev/null +++ b/src/lib/modules/file/download.svelte.ts @@ -0,0 +1,95 @@ +import axios from "axios"; +import { limitFunction } from "p-limit"; +import { decryptData } from "$lib/modules/crypto"; + +export interface FileDownloadState { + id: number; + status: + | "download-pending" + | "downloading" + | "decryption-pending" + | "decrypting" + | "decrypted" + | "canceled" + | "error"; + progress?: number; + rate?: number; + estimated?: number; + result?: ArrayBuffer; +} + +type LiveFileDownloadState = FileDownloadState & { + status: "download-pending" | "downloading" | "decryption-pending" | "decrypting"; +}; + +let downloadingFiles: FileDownloadState[] = $state([]); + +export const isFileDownloading = ( + status: FileDownloadState["status"], +): status is LiveFileDownloadState["status"] => + ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status); + +export const getFileDownloadState = (fileId: number) => { + return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status)); +}; + +export const getDownloadingFiles = () => { + return downloadingFiles.filter((file) => isFileDownloading(file.status)); +}; + +export const clearDownloadedFiles = () => { + downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status)); +}; + +const requestFileDownload = limitFunction( + async (state: FileDownloadState, id: number) => { + state.status = "downloading"; + + const res = await axios.get(`/api/file/${id}/download`, { + responseType: "arraybuffer", + onDownloadProgress: ({ progress, rate, estimated }) => { + state.progress = progress; + state.rate = rate; + state.estimated = estimated; + }, + }); + const fileEncrypted: ArrayBuffer = res.data; + + state.status = "decryption-pending"; + return fileEncrypted; + }, + { concurrency: 1 }, +); + +const decryptFile = limitFunction( + async ( + state: FileDownloadState, + fileEncrypted: ArrayBuffer, + fileEncryptedIv: string, + dataKey: CryptoKey, + ) => { + state.status = "decrypting"; + + const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey); + + state.status = "decrypted"; + state.result = fileBuffer; + return fileBuffer; + }, + { concurrency: 4 }, +); + +export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => { + downloadingFiles.push({ + id, + status: "download-pending", + }); + const state = downloadingFiles.at(-1)!; + + try { + return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey); + } catch (e) { + state.status = "error"; + throw e; + } +}; diff --git a/src/lib/modules/file/download.ts b/src/lib/modules/file/download.ts deleted file mode 100644 index b0efb30..0000000 --- a/src/lib/modules/file/download.ts +++ /dev/null @@ -1,84 +0,0 @@ -import axios from "axios"; -import { limitFunction } from "p-limit"; -import { writable, type Writable } from "svelte/store"; -import { decryptData } from "$lib/modules/crypto"; -import { fileDownloadStatusStore, type FileDownloadStatus } from "$lib/stores"; - -const requestFileDownload = limitFunction( - async (status: Writable, id: number) => { - status.update((value) => { - value.status = "downloading"; - return value; - }); - - const res = await axios.get(`/api/file/${id}/download`, { - responseType: "arraybuffer", - onDownloadProgress: ({ progress, rate, estimated }) => { - status.update((value) => { - value.progress = progress; - value.rate = rate; - value.estimated = estimated; - return value; - }); - }, - }); - const fileEncrypted: ArrayBuffer = res.data; - - status.update((value) => { - value.status = "decryption-pending"; - return value; - }); - return fileEncrypted; - }, - { concurrency: 1 }, -); - -const decryptFile = limitFunction( - async ( - status: Writable, - fileEncrypted: ArrayBuffer, - fileEncryptedIv: string, - dataKey: CryptoKey, - ) => { - status.update((value) => { - value.status = "decrypting"; - return value; - }); - - const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey); - - status.update((value) => { - value.status = "decrypted"; - value.result = fileBuffer; - return value; - }); - return fileBuffer; - }, - { concurrency: 4 }, -); - -export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => { - const status = writable({ - id, - status: "download-pending", - }); - fileDownloadStatusStore.update((value) => { - value.push(status); - return value; - }); - - try { - return await decryptFile( - status, - await requestFileDownload(status, id), - fileEncryptedIv, - dataKey, - ); - } catch (e) { - status.update((value) => { - value.status = "error"; - return value; - }); - throw e; - } -}; diff --git a/src/lib/modules/file/index.ts b/src/lib/modules/file/index.ts index 42a5613..871d299 100644 --- a/src/lib/modules/file/index.ts +++ b/src/lib/modules/file/index.ts @@ -1,3 +1,3 @@ export * from "./cache"; -export * from "./download"; -export * from "./upload"; +export * from "./download.svelte"; +export * from "./upload.svelte"; diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.svelte.ts similarity index 77% rename from src/lib/modules/file/upload.ts rename to src/lib/modules/file/upload.svelte.ts index 31aabd8..679de5b 100644 --- a/src/lib/modules/file/upload.ts +++ b/src/lib/modules/file/upload.svelte.ts @@ -1,7 +1,6 @@ import axios from "axios"; import ExifReader from "exifreader"; import { limitFunction } from "p-limit"; -import { writable, type Writable } from "svelte/store"; import { encodeToBase64, generateDataKey, @@ -17,14 +16,45 @@ import type { FileUploadRequest, FileUploadResponse, } from "$lib/server/schemas"; -import { - fileUploadStatusStore, - type MasterKey, - type HmacSecret, - type FileUploadStatus, -} from "$lib/stores"; +import type { MasterKey, HmacSecret } from "$lib/stores"; import { trpc } from "$trpc/client"; +export interface FileUploadState { + name: string; + parentId: DirectoryId; + status: + | "encryption-pending" + | "encrypting" + | "upload-pending" + | "uploading" + | "uploaded" + | "canceled" + | "error"; + progress?: number; + rate?: number; + estimated?: number; +} + +export type LiveFileUploadState = FileUploadState & { + status: "encryption-pending" | "encrypting" | "upload-pending" | "uploading"; +}; + +let uploadingFiles: FileUploadState[] = $state([]); + +const isFileUploading = (status: FileUploadState["status"]) => + ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status); + +export const getUploadingFiles = (parentId?: DirectoryId) => { + return uploadingFiles.filter( + (file) => + (parentId === undefined || file.parentId === parentId) && isFileUploading(file.status), + ); +}; + +export const clearUploadedFiles = () => { + uploadingFiles = uploadingFiles.filter((file) => isFileUploading(file.status)); +}; + const requestDuplicateFileScan = limitFunction( async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise) => { const fileBuffer = await file.arrayBuffer(); @@ -76,16 +106,8 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => { }; const encryptFile = limitFunction( - async ( - status: Writable, - file: File, - fileBuffer: ArrayBuffer, - masterKey: MasterKey, - ) => { - status.update((value) => { - value.status = "encrypting"; - return value; - }); + async (state: FileUploadState, file: File, fileBuffer: ArrayBuffer, masterKey: MasterKey) => { + state.status = "encrypting"; const fileType = getFileType(file); @@ -109,10 +131,7 @@ const encryptFile = limitFunction( const thumbnailBuffer = await thumbnail?.arrayBuffer(); const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey)); - status.update((value) => { - value.status = "upload-pending"; - return value; - }); + state.status = "upload-pending"; return { dataKeyWrapped, @@ -130,20 +149,14 @@ const encryptFile = limitFunction( ); const requestFileUpload = limitFunction( - async (status: Writable, form: FormData, thumbnailForm: FormData | null) => { - status.update((value) => { - value.status = "uploading"; - return value; - }); + async (state: FileUploadState, form: FormData, thumbnailForm: FormData | null) => { + state.status = "uploading"; const res = await axios.post("/api/file/upload", form, { onUploadProgress: ({ progress, rate, estimated }) => { - status.update((value) => { - value.progress = progress; - value.rate = rate; - value.estimated = estimated; - return value; - }); + state.progress = progress; + state.rate = rate; + state.estimated = estimated; }, }); const { file }: FileUploadResponse = res.data; @@ -157,10 +170,7 @@ const requestFileUpload = limitFunction( } } - status.update((value) => { - value.status = "uploaded"; - return value; - }); + state.status = "uploaded"; return { fileId: file }; }, @@ -176,15 +186,12 @@ export const uploadFile = async ( ): Promise< { fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined > => { - const status = writable({ + uploadingFiles.push({ name: file.name, parentId, status: "encryption-pending", }); - fileUploadStatusStore.update((value) => { - value.push(status); - return value; - }); + const state = uploadingFiles.at(-1)!; try { const { fileBuffer, fileSigned } = await requestDuplicateFileScan( @@ -193,14 +200,8 @@ export const uploadFile = async ( onDuplicate, ); if (!fileBuffer || !fileSigned) { - status.update((value) => { - value.status = "canceled"; - return value; - }); - fileUploadStatusStore.update((value) => { - value = value.filter((v) => v !== status); - return value; - }); + state.status = "canceled"; + uploadingFiles = uploadingFiles.filter((file) => file !== state); return undefined; } @@ -214,7 +215,7 @@ export const uploadFile = async ( createdAtEncrypted, lastModifiedAtEncrypted, thumbnail, - } = await encryptFile(status, file, fileBuffer, masterKey); + } = await encryptFile(state, file, fileBuffer, masterKey); const form = new FormData(); form.set( @@ -252,13 +253,10 @@ export const uploadFile = async ( thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); } - const { fileId } = await requestFileUpload(status, form, thumbnailForm); + const { fileId } = await requestFileUpload(state, form, thumbnailForm); return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; } catch (e) { - status.update((value) => { - value.status = "error"; - return value; - }); + state.status = "error"; throw e; } }; diff --git a/src/lib/modules/filesystem.ts b/src/lib/modules/filesystem.ts deleted file mode 100644 index f2995ef..0000000 --- a/src/lib/modules/filesystem.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { get, writable, type Writable } from "svelte/store"; -import { - getDirectoryInfos as getDirectoryInfosFromIndexedDB, - getDirectoryInfo as getDirectoryInfoFromIndexedDB, - storeDirectoryInfo, - 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 { unwrapDataKey, decryptString } from "$lib/modules/crypto"; -import { trpc, isTRPCClientError } from "$trpc/client"; - -export type DirectoryInfo = - | { - id: "root"; - parentId?: undefined; - dataKey?: undefined; - dataKeyVersion?: undefined; - name?: undefined; - subDirectoryIds: number[]; - fileIds: number[]; - } - | { - id: number; - parentId: DirectoryId; - dataKey?: CryptoKey; - dataKeyVersion?: Date; - name: string; - subDirectoryIds: number[]; - fileIds: number[]; - }; - -export interface FileInfo { - id: number; - parentId: DirectoryId; - dataKey?: CryptoKey; - dataKeyVersion?: Date; - contentType: string; - contentIv?: string; - name: string; - createdAt?: Date; - lastModifiedAt: Date; - categoryIds: number[]; -} - -export type CategoryInfo = - | { - id: "root"; - dataKey?: undefined; - dataKeyVersion?: undefined; - name?: undefined; - subCategoryIds: number[]; - files?: undefined; - isFileRecursive?: undefined; - } - | { - id: number; - dataKey?: CryptoKey; - dataKeyVersion?: Date; - name: string; - subCategoryIds: number[]; - files: { id: number; isRecursive: boolean }[]; - isFileRecursive: boolean; - }; - -const directoryInfoStore = new Map>(); -const fileInfoStore = new Map>(); -const categoryInfoStore = new Map>(); - -const fetchDirectoryInfoFromIndexedDB = async ( - id: DirectoryId, - info: Writable, -) => { - 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, - parentId: directory.parentId, - name: directory.name, - subDirectoryIds, - fileIds, - }); - } -}; - -const fetchDirectoryInfoFromServer = async ( - id: DirectoryId, - info: Writable, - masterKey: CryptoKey, -) => { - let data; - try { - data = await trpc().directory.get.query({ id }); - } catch (e) { - if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - info.set(null); - await deleteDirectoryInfo(id as number); - return; - } - throw new Error("Failed to fetch directory information"); - } - - const { metadata, subDirectories: subDirectoryIds, files: fileIds } = data; - - 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, - parentId: metadata!.parent, - dataKey, - dataKeyVersion: new Date(metadata!.dekVersion), - name, - subDirectoryIds, - fileIds, - }); - await storeDirectoryInfo({ id, parentId: metadata!.parent, name }); - } -}; - -const fetchDirectoryInfo = async ( - id: DirectoryId, - info: Writable, - 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); // Intended - return info; -}; - -const fetchFileInfoFromIndexedDB = async (id: number, info: Writable) => { - 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, - masterKey: CryptoKey, -) => { - let metadata; - try { - metadata = await trpc().file.get.query({ id }); - } catch (e) { - if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - info.set(null); - await deleteFileInfo(id); - return; - } - throw new Error("Failed to fetch file information"); - } - 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, - parentId: metadata.parent, - 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, 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, -) => { - if (get(info)) return; - - const [category, subCategories] = await Promise.all([ - id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined, - getCategoryInfosFromIndexedDB(id), - ]); - const subCategoryIds = subCategories.map(({ id }) => id); - - if (id === "root") { - info.set({ id, subCategoryIds }); - } else { - if (!category) return; - info.set({ - id, - name: category.name, - subCategoryIds, - files: category.files, - isFileRecursive: category.isFileRecursive, - }); - } -}; - -const fetchCategoryInfoFromServer = async ( - id: CategoryId, - info: Writable, - masterKey: CryptoKey, -) => { - let data; - try { - data = await trpc().category.get.query({ id }); - } catch (e) { - if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - info.set(null); - await deleteCategoryInfo(id as number); - return; - } - throw new Error("Failed to fetch category information"); - } - - const { metadata, subCategories } = data; - - if (id === "root") { - info.set({ id, subCategoryIds: subCategories }); - } else { - const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey); - const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey); - - let files; - try { - files = await trpc().category.files.query({ id, recurse: true }); - } catch { - throw new Error("Failed to fetch category files"); - } - - const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive })); - let isFileRecursive: boolean | undefined = undefined; - - info.update((value) => { - const newValue = { - isFileRecursive: false, - ...value, - id, - dataKey, - dataKeyVersion: new Date(metadata!.dekVersion), - name, - subCategoryIds: subCategories, - files: filesMapped, - }; - isFileRecursive = newValue.isFileRecursive; - return newValue; - }); - await storeCategoryInfo({ - id, - parentId: metadata!.parent, - name, - files: filesMapped, - isFileRecursive: isFileRecursive!, - }); - } -}; - -const fetchCategoryInfo = async ( - id: CategoryId, - info: Writable, - masterKey: CryptoKey, -) => { - await fetchCategoryInfoFromIndexedDB(id, info); - await fetchCategoryInfoFromServer(id, info, masterKey); -}; - -export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) => { - // TODO: MEK rotation - - let info = categoryInfoStore.get(categoryId); - if (!info) { - info = writable(null); - categoryInfoStore.set(categoryId, info); - } - - fetchCategoryInfo(categoryId, info, masterKey); // Intended - return info; -}; - -export const updateCategoryInfo = async ( - categoryId: number, - changes: { isFileRecursive?: boolean }, -) => { - await updateCategoryInfoInIndexedDB(categoryId, changes); - categoryInfoStore.get(categoryId)?.update((value) => { - if (!value) return value; - if (changes.isFileRecursive !== undefined) { - value.isFileRecursive = changes.isFileRecursive; - } - return value; - }); -}; diff --git a/src/lib/modules/filesystem/category.ts b/src/lib/modules/filesystem/category.ts new file mode 100644 index 0000000..dc25506 --- /dev/null +++ b/src/lib/modules/filesystem/category.ts @@ -0,0 +1,164 @@ +import * as IndexedDB from "$lib/indexedDB"; +import { trpc, isTRPCClientError } from "$trpc/client"; +import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte"; +import type { MaybeCategoryInfo } from "./types"; + +const cache = new FilesystemCache>(); + +const fetchFromIndexedDB = async (id: CategoryId) => { + const [category, subCategories] = await Promise.all([ + id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined, + IndexedDB.getCategoryInfos(id), + ]); + const files = category + ? await Promise.all( + category.files.map(async (file) => { + const fileInfo = await IndexedDB.getFileInfo(file.id); + return fileInfo + ? { + id: file.id, + contentType: fileInfo.contentType, + name: fileInfo.name, + createdAt: fileInfo.createdAt, + lastModifiedAt: fileInfo.lastModifiedAt, + isRecursive: file.isRecursive, + } + : undefined; + }), + ) + : undefined; + + if (id === "root") { + return { + id, + exists: true as const, + subCategories, + }; + } else if (category) { + return { + id, + exists: true as const, + name: category.name, + subCategories, + files: files!.filter((file) => !!file), + isFileRecursive: category.isFileRecursive, + }; + } +}; + +const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => { + try { + const { + metadata, + subCategories: subCategoriesRaw, + files: filesRaw, + } = await trpc().category.get.query({ id, recurse: true }); + const subCategories = await Promise.all( + subCategoriesRaw.map(async (category) => { + const decrypted = await decryptCategoryMetadata(category, masterKey); + const existing = await IndexedDB.getCategoryInfo(category.id); + await IndexedDB.storeCategoryInfo({ + id: category.id, + parentId: id, + name: decrypted.name, + files: existing?.files ?? [], + isFileRecursive: existing?.isFileRecursive ?? false, + }); + return { + id: category.id, + ...decrypted, + }; + }), + ); + + const existingFiles = filesRaw + ? await IndexedDB.bulkGetFileInfos(filesRaw.map((file) => file.id)) + : []; + const files = filesRaw + ? await Promise.all( + filesRaw.map(async (file, index) => { + const decrypted = await decryptFileMetadata(file, masterKey); + const existing = existingFiles[index]; + if (existing) { + const categoryIds = file.isRecursive + ? existing.categoryIds + : Array.from(new Set([...existing.categoryIds, id as number])); + await IndexedDB.storeFileInfo({ + id: file.id, + parentId: existing.parentId, + contentType: file.contentType, + name: decrypted.name, + createdAt: decrypted.createdAt, + lastModifiedAt: decrypted.lastModifiedAt, + categoryIds, + }); + } + return { + id: file.id, + contentType: file.contentType, + isRecursive: file.isRecursive, + ...decrypted, + }; + }), + ) + : undefined; + + const decryptedMetadata = metadata + ? await decryptCategoryMetadata(metadata, masterKey) + : undefined; + if (id !== "root" && metadata && decryptedMetadata) { + const existingCategory = await IndexedDB.getCategoryInfo(id); + await IndexedDB.storeCategoryInfo({ + id: id as number, + parentId: metadata.parent, + name: decryptedMetadata.name, + files: + files?.map((file) => ({ + id: file.id, + isRecursive: file.isRecursive, + })) ?? + existingCategory?.files ?? + [], + isFileRecursive: existingCategory?.isFileRecursive ?? false, + }); + } + + if (id === "root") { + return { + id, + exists: true as const, + subCategories, + }; + } else { + return { + id, + exists: true as const, + subCategories, + files, + ...decryptedMetadata!, + }; + } + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + await IndexedDB.deleteCategoryInfo(id as number); + return { id, exists: false as const }; + } + throw e; + } +}; + +export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => { + return await cache.get(id, async (isInitial, resolve) => { + if (isInitial) { + const info = await fetchFromIndexedDB(id); + if (info) { + resolve(info); + } + } + + const info = await fetchFromServer(id, masterKey); + if (info) { + resolve(info); + } + }); +}; diff --git a/src/lib/modules/filesystem/directory.ts b/src/lib/modules/filesystem/directory.ts new file mode 100644 index 0000000..6449480 --- /dev/null +++ b/src/lib/modules/filesystem/directory.ts @@ -0,0 +1,121 @@ +import * as IndexedDB from "$lib/indexedDB"; +import { monotonicResolve } from "$lib/utils"; +import { trpc, isTRPCClientError } from "$trpc/client"; +import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte"; +import type { MaybeDirectoryInfo } from "./types"; + +const cache = new FilesystemCache(); + +const fetchFromIndexedDB = async (id: DirectoryId) => { + const [directory, subDirectories, files] = await Promise.all([ + id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined, + IndexedDB.getDirectoryInfos(id), + IndexedDB.getFileInfos(id), + ]); + + if (id === "root") { + return { + id, + exists: true as const, + subDirectories, + files, + }; + } else if (directory) { + return { + id, + exists: true as const, + parentId: directory.parentId, + name: directory.name, + subDirectories, + files, + }; + } +}; + +const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => { + try { + const { + metadata, + subDirectories: subDirectoriesRaw, + files: filesRaw, + } = await trpc().directory.get.query({ id }); + const existingFiles = await IndexedDB.bulkGetFileInfos(filesRaw.map((file) => file.id)); + const [subDirectories, files, decryptedMetadata] = await Promise.all([ + Promise.all( + subDirectoriesRaw.map(async (directory) => { + const decrypted = await decryptDirectoryMetadata(directory, masterKey); + await IndexedDB.storeDirectoryInfo({ + id: directory.id, + parentId: id, + name: decrypted.name, + }); + return { + id: directory.id, + ...decrypted, + }; + }), + ), + Promise.all( + filesRaw.map(async (file, index) => { + const decrypted = await decryptFileMetadata(file, masterKey); + await IndexedDB.storeFileInfo({ + id: file.id, + parentId: id, + contentType: file.contentType, + name: decrypted.name, + createdAt: decrypted.createdAt, + lastModifiedAt: decrypted.lastModifiedAt, + categoryIds: existingFiles[index]?.categoryIds ?? [], + }); + return { + id: file.id, + contentType: file.contentType, + ...decrypted, + }; + }), + ), + metadata ? decryptDirectoryMetadata(metadata, masterKey) : undefined, + ]); + + if (id !== "root" && metadata && decryptedMetadata) { + await IndexedDB.storeDirectoryInfo({ + id, + parentId: metadata.parent, + name: decryptedMetadata.name, + }); + } + + if (id === "root") { + return { + id, + exists: true as const, + subDirectories, + files, + }; + } else { + return { + id, + exists: true as const, + parentId: metadata!.parent, + subDirectories, + files, + ...decryptedMetadata!, + }; + } + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + await IndexedDB.deleteDirectoryInfo(id as number); + return { id, exists: false as const }; + } + throw e; + } +}; + +export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => { + return await cache.get(id, (isInitial, resolve) => + monotonicResolve( + [isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)], + resolve, + ), + ); +}; diff --git a/src/lib/modules/filesystem/file.ts b/src/lib/modules/filesystem/file.ts new file mode 100644 index 0000000..45fef78 --- /dev/null +++ b/src/lib/modules/filesystem/file.ts @@ -0,0 +1,175 @@ +import * as IndexedDB from "$lib/indexedDB"; +import { monotonicResolve } from "$lib/utils"; +import { trpc, isTRPCClientError } from "$trpc/client"; +import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte"; +import type { MaybeFileInfo } from "./types"; + +const cache = new FilesystemCache(); + +const fetchFromIndexedDB = async (id: number) => { + const file = await IndexedDB.getFileInfo(id); + const categories = 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; + + if (file) { + return { + id, + exists: true as const, + parentId: file.parentId, + contentType: file.contentType, + name: file.name, + createdAt: file.createdAt, + lastModifiedAt: file.lastModifiedAt, + categories: categories!.filter((category) => !!category), + }; + } +}; + +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 }); + const [categories, decryptedMetadata] = await Promise.all([ + Promise.all( + categoriesRaw.map(async (category) => ({ + id: category.id, + ...(await decryptCategoryMetadata(category, masterKey)), + })), + ), + decryptFileMetadata(metadata, masterKey), + ]); + + await IndexedDB.storeFileInfo({ + id, + parentId: metadata.parent, + contentType: metadata.contentType, + name: decryptedMetadata.name, + createdAt: decryptedMetadata.createdAt, + lastModifiedAt: decryptedMetadata.lastModifiedAt, + categoryIds: categories.map((category) => category.id), + }); + + return { + id, + exists: true as const, + parentId: metadata.parent, + contentType: metadata.contentType, + contentIv: metadata.contentIv, + categories, + ...decryptedMetadata, + }; + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + await IndexedDB.deleteFileInfo(id); + return { id, exists: false as const }; + } + throw e; + } +}; + +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, decryptedMetadata] = await Promise.all([ + Promise.all( + file.categories.map(async (category) => ({ + id: category.id, + ...(await decryptCategoryMetadata(category, masterKey)), + })), + ), + decryptFileMetadata(file, masterKey), + ]); + + await IndexedDB.storeFileInfo({ + id: file.id, + parentId: file.parent, + contentType: file.contentType, + name: decryptedMetadata.name, + createdAt: decryptedMetadata.createdAt, + lastModifiedAt: decryptedMetadata.lastModifiedAt, + categoryIds: categories.map((category) => category.id), + }); + return { + id: file.id, + exists: true as const, + parentId: file.parent, + contentType: file.contentType, + contentIv: file.contentIv, + categories, + ...decryptedMetadata, + }; + }), + ); + + 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( + [isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)], + resolve, + ), + ); +}; + +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/index.ts b/src/lib/modules/filesystem/index.ts new file mode 100644 index 0000000..cb9e0f4 --- /dev/null +++ b/src/lib/modules/filesystem/index.ts @@ -0,0 +1,4 @@ +export * from "./category"; +export * from "./directory"; +export * from "./file"; +export * from "./types"; diff --git a/src/lib/modules/filesystem/internal.svelte.ts b/src/lib/modules/filesystem/internal.svelte.ts new file mode 100644 index 0000000..8b2b092 --- /dev/null +++ b/src/lib/modules/filesystem/internal.svelte.ts @@ -0,0 +1,130 @@ +import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; + +export class FilesystemCache { + private map = new Map>(); + + get(key: K, loader: (isInitial: boolean, resolve: (value: RV | undefined) => void) => void) { + const info = this.map.get(key); + if (info instanceof Promise) { + return info; + } + + const { promise, resolve } = Promise.withResolvers(); + if (!info) { + this.map.set(key, promise); + } + + loader(!info, (loadedInfo) => { + if (!loadedInfo) return; + + const info = this.map.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); + } + }); + + 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 ( + metadata: { dek: string; dekVersion: Date; name: string; nameIv: string }, + masterKey: CryptoKey, +) => { + const { dataKey } = await unwrapDataKey(metadata.dek, masterKey); + const name = await decryptString(metadata.name, metadata.nameIv, dataKey); + + return { + dataKey: { key: dataKey, version: metadata.dekVersion }, + name, + }; +}; + +const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => { + return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10)); +}; + +export const decryptFileMetadata = async ( + metadata: { + dek: string; + dekVersion: Date; + name: string; + nameIv: string; + createdAt?: string; + createdAtIv?: string; + lastModifiedAt: string; + lastModifiedAtIv: string; + }, + masterKey: CryptoKey, +) => { + const { dataKey } = await unwrapDataKey(metadata.dek, masterKey); + const [name, createdAt, lastModifiedAt] = await Promise.all([ + decryptString(metadata.name, metadata.nameIv, dataKey), + metadata.createdAt + ? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey) + : undefined, + decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey), + ]); + + return { + dataKey: { key: dataKey, version: metadata.dekVersion }, + name, + createdAt, + lastModifiedAt, + }; +}; + +export const decryptCategoryMetadata = decryptDirectoryMetadata; diff --git a/src/lib/modules/filesystem/types.ts b/src/lib/modules/filesystem/types.ts new file mode 100644 index 0000000..15b0e93 --- /dev/null +++ b/src/lib/modules/filesystem/types.ts @@ -0,0 +1,71 @@ +export type DataKey = { key: CryptoKey; version: Date }; +type AllUndefined = { [K in keyof T]?: undefined }; + +interface LocalDirectoryInfo { + id: number; + parentId: DirectoryId; + dataKey?: DataKey; + name: string; + subDirectories: SubDirectoryInfo[]; + files: SummarizedFileInfo[]; +} + +interface RootDirectoryInfo { + id: "root"; + parentId?: undefined; + dataKey?: undefined; + name?: undefined; + subDirectories: SubDirectoryInfo[]; + files: SummarizedFileInfo[]; +} + +export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo; +export type SubDirectoryInfo = Omit; +export type MaybeDirectoryInfo = + | (DirectoryInfo & { exists: true }) + | ({ id: DirectoryId; exists: false } & AllUndefined>); + +export interface FileInfo { + id: number; + parentId: DirectoryId; + dataKey?: DataKey; + contentType: string; + contentIv?: string; + name: string; + createdAt?: Date; + lastModifiedAt: Date; + categories: { id: number; name: string }[]; +} + +export type SummarizedFileInfo = Omit; +export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean }; +export type MaybeFileInfo = + | (FileInfo & { exists: true }) + | ({ id: number; exists: false } & AllUndefined>); + +interface LocalCategoryInfo { + id: number; + dataKey?: DataKey; + name: string; + subCategories: SubCategoryInfo[]; + files: CategoryFileInfo[]; + isFileRecursive: boolean; +} + +interface RootCategoryInfo { + id: "root"; + dataKey?: undefined; + name?: undefined; + subCategories: SubCategoryInfo[]; + files?: undefined; + isFileRecursive?: undefined; +} + +export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo; +export type SubCategoryInfo = Omit< + LocalCategoryInfo, + "subCategories" | "files" | "isFileRecursive" +>; +export type MaybeCategoryInfo = + | (CategoryInfo & { exists: true }) + | ({ id: CategoryId; exists: false } & AllUndefined>); diff --git a/src/lib/server/db/category.ts b/src/lib/server/db/category.ts index f5c22ff..e20138c 100644 --- a/src/lib/server/db/category.ts +++ b/src/lib/server/db/category.ts @@ -2,8 +2,6 @@ import { IntegrityError } from "./error"; import db from "./kysely"; import type { Ciphertext } from "./schema"; -export type CategoryId = "root" | number; - interface Category { id: number; parentId: CategoryId; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index c3169fc..6a0a062 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -1,11 +1,10 @@ -import { sql, type NotNull } from "kysely"; +import { sql } from "kysely"; +import { jsonArrayFrom } from "kysely/helpers/postgres"; import pg from "pg"; import { IntegrityError } from "./error"; import db from "./kysely"; import type { Ciphertext } from "./schema"; -export type DirectoryId = "root" | number; - interface Directory { id: number; parentId: DirectoryId; @@ -38,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 @@ -306,39 +313,51 @@ export const getAllFilesByCategory = async ( recurse: boolean, ) => { const files = await db - .withRecursive("cte", (db) => + .withRecursive("category_tree", (db) => db .selectFrom("category") - .leftJoin("file_category", "category.id", "file_category.category_id") - .select(["id", "parent_id", "user_id", "file_category.file_id"]) - .select(sql`0`.as("depth")) + .select(["id", sql`0`.as("depth")]) .where("id", "=", categoryId) + .where("user_id", "=", userId) .$if(recurse, (qb) => qb.unionAll((db) => db .selectFrom("category") - .leftJoin("file_category", "category.id", "file_category.category_id") - .innerJoin("cte", "category.parent_id", "cte.id") - .select([ - "category.id", - "category.parent_id", - "category.user_id", - "file_category.file_id", - ]) - .select(sql`cte.depth + 1`.as("depth")), + .innerJoin("category_tree", "category.parent_id", "category_tree.id") + .select(["category.id", sql`depth + 1`.as("depth")]), ), ), ) - .selectFrom("cte") + .selectFrom("category_tree") + .innerJoin("file_category", "category_tree.id", "file_category.category_id") + .innerJoin("file", "file_category.file_id", "file.id") .select(["file_id", "depth"]) + .selectAll("file") .distinctOn("file_id") - .where("user_id", "=", userId) - .where("file_id", "is not", null) - .$narrowType<{ file_id: NotNull }>() .orderBy("file_id") .orderBy("depth") .execute(); - return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 })); + return files.map( + (file) => + ({ + id: file.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, + isRecursive: file.depth > 0, + }) satisfies File & { isRecursive: boolean }, + ); }; export const getAllFileIds = async (userId: number) => { @@ -390,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, @@ -476,10 +540,20 @@ export const addFileToCategory = async (fileId: number, categoryId: number) => { export const getAllFileCategories = async (fileId: number) => { const categories = await db .selectFrom("file_category") - .select("category_id") + .innerJoin("category", "file_category.category_id", "category.id") + .selectAll("category") .where("file_id", "=", fileId) .execute(); - return categories.map(({ category_id }) => ({ id: category_id })); + 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/lib/stores/file.ts b/src/lib/stores/file.ts deleted file mode 100644 index 61db95d..0000000 --- a/src/lib/stores/file.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { writable, type Writable } from "svelte/store"; - -export interface FileUploadStatus { - name: string; - parentId: "root" | number; - status: - | "encryption-pending" - | "encrypting" - | "upload-pending" - | "uploading" - | "uploaded" - | "canceled" - | "error"; - progress?: number; - rate?: number; - estimated?: number; -} - -export interface FileDownloadStatus { - id: number; - status: - | "download-pending" - | "downloading" - | "decryption-pending" - | "decrypting" - | "decrypted" - | "canceled" - | "error"; - progress?: number; - rate?: number; - estimated?: number; - result?: ArrayBuffer; -} - -export const fileUploadStatusStore = writable[]>([]); - -export const fileDownloadStatusStore = writable[]>([]); - -export const isFileUploading = ( - status: FileUploadStatus["status"], -): status is "encryption-pending" | "encrypting" | "upload-pending" | "uploading" => { - return ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status); -}; - -export const isFileDownloading = ( - status: FileDownloadStatus["status"], -): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => { - return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status); -}; diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 537209a..668f46f 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -1,2 +1 @@ -export * from "./file"; export * from "./key"; diff --git a/src/lib/types/filesystem.d.ts b/src/lib/types/filesystem.d.ts new file mode 100644 index 0000000..2cb91a7 --- /dev/null +++ b/src/lib/types/filesystem.d.ts @@ -0,0 +1,2 @@ +type DirectoryId = "root" | number; +type CategoryId = "root" | number; diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 1db9577..9dc3631 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,3 +1,4 @@ export * from "./format"; export * from "./gotoStateful"; +export * from "./promise"; export * from "./sort"; diff --git a/src/lib/utils/promise.ts b/src/lib/utils/promise.ts new file mode 100644 index 0000000..9e841c8 --- /dev/null +++ b/src/lib/utils/promise.ts @@ -0,0 +1,16 @@ +export const monotonicResolve = ( + promises: (Promise | false)[], + callback: (value: T) => void, +) => { + let latestResolvedIndex = -1; + promises + .filter((promise) => !!promise) + .forEach((promise, index) => { + promise.then((value) => { + if (index > latestResolvedIndex) { + latestResolvedIndex = index; + callback(value); + } + }); + }); +}; diff --git a/src/lib/utils/sort.ts b/src/lib/utils/sort.ts index 2385e55..a92c444 100644 --- a/src/lib/utils/sort.ts +++ b/src/lib/utils/sort.ts @@ -32,7 +32,7 @@ const sortByDateAsc: SortFunc = ({ date: a }, { date: b }) => { const sortByDateDesc: SortFunc = (a, b) => -sortByDateAsc(a, b); -export const sortEntries = (entries: T[], sortBy: SortBy) => { +export const sortEntries = (entries: T[], sortBy = SortBy.NAME_ASC) => { let sortFunc: SortFunc; switch (sortBy) { @@ -54,4 +54,5 @@ export const sortEntries = (entries: T[], sortBy: SortBy) = } entries.sort(sortFunc); + return entries; }; diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 3249bf2..7a04a7f 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -1,19 +1,14 @@ -{#if $category} - - - - (category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))} - onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} - subCategoryCreatePosition="top" - /> - {#if $category.id !== "root"} - - - - {/if} - - -{/if} +{#await categoryInfoPromise then categoryInfo} + {#if categoryInfo?.exists} + + + + (categoryInfoPromise = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))} + onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} + subCategoryCreatePosition="top" + /> + {#if categoryInfo.id !== "root"} + + + + {/if} + + - { - if (await requestCategoryCreation(name, $category!.id, $masterKeyStore?.get(1)!)) { - category = getCategoryInfo($category!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> + { + if (await requestCategoryCreation(name, categoryInfo.id, $masterKeyStore?.get(1)!)) { + categoryInfoPromise = getCategoryInfo(categoryInfo.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} + /> + {/if} +{/await} diff --git a/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte b/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte index 150669e..392cf6b 100644 --- a/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte +++ b/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte @@ -1,32 +1,31 @@ -{#if $status && isFileDownloading($status.status)} +{#if isFileDownloading(state.status)}

- {#if $status.status === "download-pending"} + {#if state.status === "download-pending"} 다운로드를 기다리는 중 - {:else if $status.status === "downloading"} + {:else if state.status === "downloading"} 다운로드하는 중 - {:else if $status.status === "decryption-pending"} + {:else if state.status === "decryption-pending"} 복호화를 기다리는 중 - {:else if $status.status === "decrypting"} + {:else if state.status === "decrypting"} 복호화하는 중 {/if}

- {#if $status.status === "downloading"} + {#if state.status === "downloading"} 전송됨 - {Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)} + {Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)} {/if}

diff --git a/src/routes/(fullscreen)/file/downloads/+page.svelte b/src/routes/(fullscreen)/file/downloads/+page.svelte index e1bad0e..860901f 100644 --- a/src/routes/(fullscreen)/file/downloads/+page.svelte +++ b/src/routes/(fullscreen)/file/downloads/+page.svelte @@ -1,19 +1,31 @@ @@ -23,8 +35,10 @@
- {#each downloadingFiles as status} - + {#each downloadingFiles as { info, state } (info.id)} + {#if info.exists} + + {/if} {/each}
diff --git a/src/routes/(fullscreen)/file/downloads/File.svelte b/src/routes/(fullscreen)/file/downloads/File.svelte index 3bfe292..d70428e 100644 --- a/src/routes/(fullscreen)/file/downloads/File.svelte +++ b/src/routes/(fullscreen)/file/downloads/File.svelte @@ -1,7 +1,6 @@ -{#if $fileInfo} -
-
- {#if $status.status === "download-pending"} - - {:else if $status.status === "downloading"} - - {:else if $status.status === "decryption-pending"} - - {:else if $status.status === "decrypting"} - - {:else if $status.status === "decrypted"} - - {:else if $status.status === "error"} - - {/if} -
-
-

- {$fileInfo.name} -

-

- {#if $status.status === "download-pending"} - 다운로드를 기다리는 중 - {:else if $status.status === "downloading"} - 전송됨 - {Math.floor(($status.progress ?? 0) * 100)}% · - {formatNetworkSpeed(($status.rate ?? 0) * 8)} - {:else if $status.status === "decryption-pending"} - 복호화를 기다리는 중 - {:else if $status.status === "decrypting"} - 복호화하는 중 - {:else if $status.status === "decrypted"} - 다운로드 완료 - {:else if $status.status === "error"} - 다운로드 실패 - {/if} -

-
+
+
+ {#if state.status === "download-pending"} + + {:else if state.status === "downloading"} + + {:else if state.status === "decryption-pending"} + + {:else if state.status === "decrypting"} + + {:else if state.status === "decrypted"} + + {:else if state.status === "error"} + + {/if}
-{/if} +
+

+ {info.name} +

+

+ {#if state.status === "download-pending"} + 다운로드를 기다리는 중 + {:else if state.status === "downloading"} + 전송됨 + {Math.floor((state.progress ?? 0) * 100)}% · + {formatNetworkSpeed((state.rate ?? 0) * 8)} + {:else if state.status === "decryption-pending"} + 복호화를 기다리는 중 + {:else if state.status === "decrypting"} + 복호화하는 중 + {:else if state.status === "decrypted"} + 다운로드 완료 + {:else if state.status === "error"} + 다운로드 실패 + {/if} +

+
+
diff --git a/src/routes/(fullscreen)/file/uploads/+page.svelte b/src/routes/(fullscreen)/file/uploads/+page.svelte index d456322..d280e7f 100644 --- a/src/routes/(fullscreen)/file/uploads/+page.svelte +++ b/src/routes/(fullscreen)/file/uploads/+page.svelte @@ -1,19 +1,12 @@ @@ -23,8 +16,8 @@
- {#each uploadingFiles as status} - + {#each uploadingFiles as file} + {/each}
diff --git a/src/routes/(fullscreen)/file/uploads/File.svelte b/src/routes/(fullscreen)/file/uploads/File.svelte index 2435240..7b40ac5 100644 --- a/src/routes/(fullscreen)/file/uploads/File.svelte +++ b/src/routes/(fullscreen)/file/uploads/File.svelte @@ -1,6 +1,5 @@
- {#if $status.status === "encryption-pending"} + {#if state.status === "encryption-pending"} - {:else if $status.status === "encrypting"} + {:else if state.status === "encrypting"} - {:else if $status.status === "upload-pending"} + {:else if state.status === "upload-pending"} - {:else if $status.status === "uploading"} + {:else if state.status === "uploading"} - {:else if $status.status === "uploaded"} + {:else if state.status === "uploaded"} - {:else if $status.status === "error"} + {:else if state.status === "error"} {/if}
-

- {$status.name} +

+ {state.name}

- {#if $status.status === "encryption-pending"} + {#if state.status === "encryption-pending"} 준비 중 - {:else if $status.status === "encrypting"} + {:else if state.status === "encrypting"} 암호화하는 중 - {:else if $status.status === "upload-pending"} + {:else if state.status === "upload-pending"} 업로드를 기다리는 중 - {:else if $status.status === "uploading"} + {:else if state.status === "uploading"} 전송됨 - {Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)} - {:else if $status.status === "uploaded"} + {Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)} + {:else if state.status === "uploaded"} 업로드 완료 - {:else if $status.status === "error"} + {:else if state.status === "error"} 업로드 실패 {/if}

diff --git a/src/routes/(fullscreen)/gallery/+page.svelte b/src/routes/(fullscreen)/gallery/+page.svelte index 1826c47..01eed54 100644 --- a/src/routes/(fullscreen)/gallery/+page.svelte +++ b/src/routes/(fullscreen)/gallery/+page.svelte @@ -1,18 +1,18 @@ @@ -22,5 +22,8 @@ - goto(`/file/${id}`)} /> + file?.exists)} + onFileClick={({ id }) => goto(`/file/${id}?from=gallery`)} + /> diff --git a/src/routes/(fullscreen)/settings/cache/+page.svelte b/src/routes/(fullscreen)/settings/cache/+page.svelte index cf8192d..1d6f0c4 100644 --- a/src/routes/(fullscreen)/settings/cache/+page.svelte +++ b/src/routes/(fullscreen)/settings/cache/+page.svelte @@ -1,42 +1,39 @@ @@ -55,8 +52,8 @@

캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.

- {#each fileCache as { index, fileInfo }} - + {#each fileCache as { index, info } (info.id)} + {/each}
diff --git a/src/routes/(fullscreen)/settings/cache/File.svelte b/src/routes/(fullscreen)/settings/cache/File.svelte index 581d144..2727381 100644 --- a/src/routes/(fullscreen)/settings/cache/File.svelte +++ b/src/routes/(fullscreen)/settings/cache/File.svelte @@ -1,7 +1,6 @@
- {#if $info} + {#if info.exists}
@@ -28,8 +27,8 @@
{/if}
- {#if $info} -

{$info.name}

+ {#if info.exists} +

{info.name}

{:else}

삭제된 파일

{/if} diff --git a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte index d9cd692..2c06964 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte @@ -1,18 +1,13 @@ @@ -56,13 +51,15 @@ {persistentStates.files.length}개 파일의 썸네일이 존재하지 않아요.

- {#each persistentStates.files as { info, status }} - goto(`/file/${id}`)} - onGenerateThumbnailClick={requestThumbnailGeneration} - /> + {#each persistentStates.files as { info, status } (info.id)} + {#if info.exists} + goto(`/file/${id}`)} + onGenerateThumbnailClick={requestThumbnailGeneration} + /> + {/if} {/each}
diff --git a/src/routes/(fullscreen)/settings/thumbnail/File.svelte b/src/routes/(fullscreen)/settings/thumbnail/File.svelte index 93c23ad..6b3e0d9 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/File.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/File.svelte @@ -20,27 +20,25 @@ import IconCamera from "~icons/material-symbols/camera"; interface Props { - info: Writable; - onclick: (selectedFile: FileInfo) => void; - onGenerateThumbnailClick: (selectedFile: FileInfo) => void; + info: FileInfo; + onclick: (file: FileInfo) => void; + onGenerateThumbnailClick: (file: FileInfo) => void; generationStatus?: Writable; } let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props(); -{#if $info} - onclick($info)} - actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined} - onActionButtonClick={() => onGenerateThumbnailClick($info)} - actionButtonClass="text-gray-800" - > - {@const subtext = - $generationStatus && $generationStatus !== "uploaded" - ? subtexts[$generationStatus] - : formatDateTime($info.createdAt ?? $info.lastModifiedAt)} - - -{/if} + onclick(info)} + actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined} + onActionButtonClick={() => onGenerateThumbnailClick(info)} + actionButtonClass="text-gray-800" +> + {@const subtext = + $generationStatus && $generationStatus !== "uploaded" + ? subtexts[$generationStatus] + : formatDateTime(info.createdAt ?? info.lastModifiedAt)} + + diff --git a/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts b/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts index d8f288c..23e863f 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts +++ b/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts @@ -2,7 +2,7 @@ import { limitFunction } from "p-limit"; import { get, writable, type Writable } from "svelte/store"; import { encryptData } from "$lib/modules/crypto"; import { storeFileThumbnailCache } from "$lib/modules/file"; -import type { FileInfo } from "$lib/modules/filesystem"; +import type { FileInfo, MaybeFileInfo } from "$lib/modules/filesystem"; import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail"; import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file"; @@ -17,7 +17,7 @@ export type GenerationStatus = interface File { id: number; - info: Writable; + info: MaybeFileInfo; status?: Writable; } @@ -129,7 +129,11 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => { let fileSize = 0; try { - const file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey!); + const file = await requestFileDownload( + fileInfo.id, + fileInfo.contentIv!, + fileInfo.dataKey?.key!, + ); fileSize = file.byteLength; memoryUsage += fileSize; @@ -141,11 +145,11 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => { status, file, fileInfo.contentType, - fileInfo.dataKey!, + fileInfo.dataKey?.key!, ); if ( !thumbnail || - !(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail)) + !(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKey?.version!, thumbnail)) ) { status.set("error"); } diff --git a/src/routes/(main)/category/[[id]]/+page.svelte b/src/routes/(main)/category/[[id]]/+page.svelte index 9b3e195..f57b402 100644 --- a/src/routes/(main)/category/[[id]]/+page.svelte +++ b/src/routes/(main)/category/[[id]]/+page.svelte @@ -1,9 +1,8 @@ @@ -50,68 +34,70 @@ 카테고리
-{#if data.id !== "root"} - -{/if} -
- {#if $info && isFileRecursive !== undefined} - goto(`/file/${id}?from=category`)} - onFileRemoveClick={async ({ id }) => { - await requestFileRemovalFromCategory(id, data.id as number); - info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME +{#await infoPromise then info} + {#if info?.exists} + {#if info.id !== "root"} + + {/if} +
+ goto(`/file/${id}?from=category`)} + onFileRemoveClick={async ({ id }) => { + await requestFileRemovalFromCategory(id, data.id as number); + infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + }} + onSubCategoryClick={({ id }) => goto(`/category/${id}`)} + onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} + onSubCategoryMenuClick={(subCategory) => { + context.selectedCategory = subCategory; + isCategoryMenuBottomSheetOpen = true; + }} + /> +
+ + { + if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { + infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; }} - onSubCategoryClick={({ id }) => goto(`/category/${id}`)} - onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} - onSubCategoryMenuClick={(subCategory) => { - context.selectedCategory = subCategory; - isCategoryMenuBottomSheetOpen = true; + /> + + { + isCategoryMenuBottomSheetOpen = false; + isCategoryRenameModalOpen = true; + }} + onDeleteClick={() => { + isCategoryMenuBottomSheetOpen = false; + isCategoryDeleteModalOpen = true; + }} + /> + { + if (await requestCategoryRename(context.selectedCategory!, newName)) { + infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} + /> + { + if (await requestCategoryDeletion(context.selectedCategory!)) { + infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; }} /> {/if} -
- - { - if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { - info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> - - { - isCategoryMenuBottomSheetOpen = false; - isCategoryRenameModalOpen = true; - }} - onDeleteClick={() => { - isCategoryMenuBottomSheetOpen = false; - isCategoryDeleteModalOpen = true; - }} -/> - { - if (await requestCategoryRename(context.selectedCategory!, newName)) { - info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> - { - if (await requestCategoryDeletion(context.selectedCategory!)) { - info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> +{/await} diff --git a/src/routes/(main)/category/[[id]]/service.svelte.ts b/src/routes/(main)/category/[[id]]/service.svelte.ts index 18f68fd..c415cf5 100644 --- a/src/routes/(main)/category/[[id]]/service.svelte.ts +++ b/src/routes/(main)/category/[[id]]/service.svelte.ts @@ -17,12 +17,17 @@ export const useContext = () => { }; export const requestCategoryRename = async (category: SelectedCategory, newName: string) => { - const newNameEncrypted = await encryptString(newName, category.dataKey); + if (!category.dataKey) { + // TODO: Error Handling + return false; + } + + const newNameEncrypted = await encryptString(newName, category.dataKey.key); try { await trpc().category.rename.mutate({ id: category.id, - dekVersion: category.dataKeyVersion, + dekVersion: category.dataKey.version, name: newNameEncrypted.ciphertext, nameIv: newNameEncrypted.iv, }); diff --git a/src/routes/(main)/directory/[[id]]/+page.svelte b/src/routes/(main)/directory/[[id]]/+page.svelte index a4edf30..8edd04b 100644 --- a/src/routes/(main)/directory/[[id]]/+page.svelte +++ b/src/routes/(main)/directory/[[id]]/+page.svelte @@ -1,11 +1,10 @@ @@ -89,106 +88,106 @@ -
- {#if showTopBar} - - {/if} - {#if $info} -
-
- goto("/file/uploads")} /> - goto("/file/downloads")} /> -
- {#key $info} +{#await infoPromise then info} + {#if info?.exists} +
+ {#if showTopBar} + + {/if} +
+
+ goto("/file/uploads")} /> + goto("/file/downloads")} /> +
goto(`/${type}/${id}`)} onEntryMenuClick={(entry) => { context.selectedEntry = entry; isEntryMenuBottomSheetOpen = true; }} - showParentEntry={isFromFilePage && $info.parentId !== undefined} + showParentEntry={isFromFilePage && info.parentId !== undefined} onParentClick={() => goto( - $info.parentId === "root" + info.parentId === "root" ? "/directory?from=file" - : `/directory/${$info.parentId}?from=file`, + : `/directory/${info.parentId}?from=file`, )} /> - {/key} +
+ + { + isEntryCreateBottomSheetOpen = true; + }} + class="bottom-24 right-4" + /> + { + isEntryCreateBottomSheetOpen = false; + isDirectoryCreateModalOpen = true; + }} + onFileUploadClick={() => { + isEntryCreateBottomSheetOpen = false; + fileInput?.click(); + }} + /> + { + if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { + infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} + /> + { + resolveForDuplicateFileModal?.(false); + isDuplicateFileModalOpen = false; + }} + onUploadClick={() => { + resolveForDuplicateFileModal?.(true); + isDuplicateFileModalOpen = false; + }} + /> + + { + isEntryMenuBottomSheetOpen = false; + isEntryRenameModalOpen = true; + }} + onDeleteClick={() => { + isEntryMenuBottomSheetOpen = false; + isEntryDeleteModalOpen = true; + }} + /> + { + if (await requestEntryRename(context.selectedEntry!, newName)) { + infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} + /> + { + if (await requestEntryDeletion(context.selectedEntry!)) { + infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} + /> {/if} -
- - { - isEntryCreateBottomSheetOpen = true; - }} - class="bottom-24 right-4" -/> - { - isEntryCreateBottomSheetOpen = false; - isDirectoryCreateModalOpen = true; - }} - onFileUploadClick={() => { - isEntryCreateBottomSheetOpen = false; - fileInput?.click(); - }} -/> - { - if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> - { - resolveForDuplicateFileModal?.(false); - isDuplicateFileModalOpen = false; - }} - onUploadClick={() => { - resolveForDuplicateFileModal?.(true); - isDuplicateFileModalOpen = false; - }} -/> - - { - isEntryMenuBottomSheetOpen = false; - isEntryRenameModalOpen = true; - }} - onDeleteClick={() => { - isEntryMenuBottomSheetOpen = false; - isEntryDeleteModalOpen = true; - }} -/> - { - if (await requestEntryRename(context.selectedEntry!, newName)) { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> - { - if (await requestEntryDeletion(context.selectedEntry!)) { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> +{/await} diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte index 527bd1b..b1ac220 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte @@ -1,21 +1,9 @@ -{#if subDirectories.length + files.length > 0 || showParentEntry} -
- {#if showParentEntry} - - - - {/if} - {#each subDirectories as { info }} - - {/each} - {#if files.length > 0} - 56 + (index + 1 < files.length ? 4 : 0)} - > - {#snippet item(index)} - {@const file = files[index]!} -
- {#if file.type === "file"} - - {:else} - - {/if} -
- {/snippet} -
- {/if} -
+{#if entries.length > 0} + 56} itemGap={4} class="pb-[4.5rem]"> + {#snippet item(index)} + {@const entry = entries[index]!} + {#if entry.type === "parent"} + + + + {:else if entry.type === "directory"} + + {:else if entry.type === "file"} + + {:else} + + {/if} + {/snippet} + {:else}

폴더가 비어 있어요.

diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte index 67d7e36..41f1a84 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte @@ -1,66 +1,52 @@ -{#if $info} - + action(onclick)} + actionButtonIcon={IconMoreVert} + onActionButtonClick={() => action(onOpenMenuClick)} +> + {#await thumbnailPromise} - -{/if} + {:then thumbnail} + + {/await} + diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte index 5454695..018a1e5 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte @@ -1,44 +1,29 @@ -{#if $info} - - - -{/if} + action(onclick)} + actionButtonIcon={IconMoreVert} + onActionButtonClick={() => action(onOpenMenuClick)} +> + + diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte index bf5e85a..0ec7263 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte @@ -1,38 +1,35 @@ -{#if isFileUploading($status.status)} -
-
- -
-
-

- {$status.name} -

-

- {#if $status.status === "encryption-pending"} - 준비 중 - {:else if $status.status === "encrypting"} - 암호화하는 중 - {:else if $status.status === "upload-pending"} - 업로드를 기다리는 중 - {:else if $status.status === "uploading"} - 전송됨 {Math.floor(($status.progress ?? 0) * 100)}% · - {formatNetworkSpeed(($status.rate ?? 0) * 8)} - {/if} -

-
+
+
+
-{/if} +
+

+ {state.name} +

+

+ {#if state.status === "encryption-pending"} + 준비 중 + {:else if state.status === "encrypting"} + 암호화하는 중 + {:else if state.status === "upload-pending"} + 업로드를 기다리는 중 + {:else if state.status === "uploading"} + 전송됨 {Math.floor((state.progress ?? 0) * 100)}% · + {formatNetworkSpeed((state.rate ?? 0) * 8)} + {/if} +

+
+
diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts deleted file mode 100644 index d4b47f8..0000000 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts +++ /dev/null @@ -1 +0,0 @@ -export { requestFileThumbnailDownload } from "$lib/services/file"; diff --git a/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte b/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte index 18bb159..590cb8f 100644 --- a/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte +++ b/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte @@ -1,7 +1,5 @@ {#if downloadingFiles.length > 0} diff --git a/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte b/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte index 1ac40b3..578c368 100644 --- a/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte +++ b/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte @@ -1,7 +1,5 @@ {#if uploadingFiles.length > 0} diff --git a/src/routes/(main)/directory/[[id]]/service.svelte.ts b/src/routes/(main)/directory/[[id]]/service.svelte.ts index c94cc1e..f83bbaf 100644 --- a/src/routes/(main)/directory/[[id]]/service.svelte.ts +++ b/src/routes/(main)/directory/[[id]]/service.svelte.ts @@ -8,14 +8,14 @@ import { deleteFileThumbnailCache, uploadFile, } from "$lib/modules/file"; +import type { DataKey } from "$lib/modules/filesystem"; import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores"; import { trpc } from "$trpc/client"; export interface SelectedEntry { type: "directory" | "file"; id: number; - dataKey: CryptoKey; - dataKeyVersion: Date; + dataKey: DataKey | undefined; name: string; } @@ -97,20 +97,25 @@ export const requestFileUpload = async ( }; export const requestEntryRename = async (entry: SelectedEntry, newName: string) => { - const newNameEncrypted = await encryptString(newName, entry.dataKey); + if (!entry.dataKey) { + // TODO: Error Handling + return false; + } + + const newNameEncrypted = await encryptString(newName, entry.dataKey.key); try { if (entry.type === "directory") { await trpc().directory.rename.mutate({ id: entry.id, - dekVersion: entry.dataKeyVersion, + dekVersion: entry.dataKey.version, name: newNameEncrypted.ciphertext, nameIv: newNameEncrypted.iv, }); } else { await trpc().file.rename.mutate({ id: entry.id, - dekVersion: entry.dataKeyVersion, + dekVersion: entry.dataKey.version, name: newNameEncrypted.ciphertext, nameIv: newNameEncrypted.iv, }); diff --git a/src/routes/(main)/home/+page.svelte b/src/routes/(main)/home/+page.svelte index 0ace1ab..bd3d92d 100644 --- a/src/routes/(main)/home/+page.svelte +++ b/src/routes/(main)/home/+page.svelte @@ -1,17 +1,23 @@ @@ -27,8 +33,10 @@ {#if mediaFiles.length > 0}
- {#each mediaFiles as file} - goto(`/file/${id}`)} /> + {#each mediaFiles as file (file.id)} + {#if file.exists} + goto(`/file/${id}`)} /> + {/if} {/each}
{/if} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e4bca97..9aadffd 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,24 +1,14 @@