6 Commits

Author SHA1 Message Date
static
dfffa004ac Merge pull request #13 from kmc7468/dev
v0.5.1
2025-07-12 19:56:12 +09:00
static
0cd55a413d Merge pull request #12 from kmc7468/dev
v0.5.0
2025-07-12 06:01:08 +09:00
static
361d966a59 Merge pull request #10 from kmc7468/dev
v0.4.0
2025-01-30 21:06:50 +09:00
static
aef43b8bfa Merge pull request #6 from kmc7468/dev
v0.3.0
2025-01-18 13:29:09 +09:00
static
7f128cccf6 Merge pull request #5 from kmc7468/dev
v0.2.0
2025-01-13 03:53:14 +09:00
static
a198e5f6dc Merge pull request #2 from kmc7468/dev
v0.1.0
2025-01-09 06:24:31 +09:00
43 changed files with 763 additions and 1296 deletions

View File

@@ -1,13 +1,11 @@
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import svelteQuery from "@tanstack/eslint-plugin-query";
import prettier from "eslint-config-prettier";
import js from "@eslint/js";
import { includeIgnoreFile } from "@eslint/compat";
import svelte from "eslint-plugin-svelte";
import tailwind from "eslint-plugin-tailwindcss";
import globals from "globals";
import { fileURLToPath } from "node:url";
import ts from "typescript-eslint";
import { fileURLToPath } from "url";
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default ts.config(
@@ -28,11 +26,11 @@ export default ts.config(
},
{
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: ts.parser,
},
},
},
...svelteQuery.configs["flat/recommended"],
);

View File

@@ -21,8 +21,6 @@
"@sveltejs/adapter-node": "^5.2.13",
"@sveltejs/kit": "^2.22.5",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@tanstack/svelte-query": "^5.83.0",
"@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.8",

34
pnpm-lock.yaml generated
View File

@@ -48,12 +48,6 @@ importers:
'@sveltejs/vite-plugin-svelte':
specifier: ^4.0.4
version: 4.0.4(svelte@5.35.6)(vite@5.4.19(@types/node@24.0.13))
'@tanstack/eslint-plugin-query':
specifier: ^5.81.2
version: 5.81.2(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3)
'@tanstack/svelte-query':
specifier: ^5.83.0
version: 5.83.0(svelte@5.35.6)
'@types/file-saver':
specifier: ^2.0.7
version: 2.0.7
@@ -589,19 +583,6 @@ packages:
svelte: ^5.0.0-next.96 || ^5.0.0
vite: ^5.0.0
'@tanstack/eslint-plugin-query@5.81.2':
resolution: {integrity: sha512-h4k6P6fm5VhKP5NkK+0TTVpGGyKQdx6tk7NYYG7J7PkSu7ClpLgBihw7yzK8N3n5zPaF3IMyErxfoNiXWH/3/A==}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
'@tanstack/query-core@5.83.0':
resolution: {integrity: sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==}
'@tanstack/svelte-query@5.83.0':
resolution: {integrity: sha512-8tNXhuoizntZXnAzo4yqUWgZZnklQkXGUNpb3YreW68DyCBhhrGbErnrODQs3fVc2ABcMvAHIki5uErbdzXH1A==}
peerDependencies:
svelte: ^3.54.0 || ^4.0.0 || ^5.0.0
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
@@ -2387,21 +2368,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tanstack/eslint-plugin-query@5.81.2(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3)':
dependencies:
'@typescript-eslint/utils': 8.36.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3)
eslint: 9.30.1(jiti@2.4.2)
transitivePeerDependencies:
- supports-color
- typescript
'@tanstack/query-core@5.83.0': {}
'@tanstack/svelte-query@5.83.0(svelte@5.35.6)':
dependencies:
'@tanstack/query-core': 5.83.0
svelte: 5.35.6
'@types/cookie@0.6.0': {}
'@types/estree@1.0.8': {}

View File

@@ -1,16 +1,14 @@
<script lang="ts">
import type { Component } from "svelte";
import { untrack, type Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { derived } from "svelte/store";
import type { CategoryId } from "$lib/indexedDB";
import { getCategoryInfo, type SubCategoryInfo } from "$lib/modules/filesystem2";
import { get, type Writable } from "svelte/store";
import type { CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import Category from "./Category.svelte";
import type { SelectedCategory } from "./service";
interface Props {
categoryIds: CategoryId[];
categories: Writable<CategoryInfo | null>[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
@@ -18,33 +16,42 @@
}
let {
categoryIds,
categories,
categoryMenuIcon,
onCategoryClick,
onCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
}: Props = $props();
let categories = $derived(
derived(
categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)),
(infos) => {
const categories = infos
.filter(($info) => $info.status === "success")
.map(($info) => ({
name: $info.data.name,
info: $info.data as SubCategoryInfo,
let categoriesWithName: { name?: string; info: Writable<CategoryInfo | null> }[] = $state([]);
$effect(() => {
categoriesWithName = categories.map((category) => ({
name: get(category)?.name,
info: category,
}));
sortEntries(categories, sortBy);
return categories;
},
),
const sort = () => {
sortEntries(categoriesWithName, sortBy);
};
return untrack(() => {
sort();
const unsubscribes = categoriesWithName.map((category) =>
category.info.subscribe((value) => {
if (category.name === value?.name) return;
category.name = value?.name;
sort();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
{#if $categories.length > 0}
{#if categoriesWithName.length > 0}
<div class="space-y-1">
{#each $categories as { info }}
{#each categoriesWithName as { info }}
<Category
{info}
menuIcon={categoryMenuIcon}

View File

@@ -1,13 +1,14 @@
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { CategoryLabel } from "$lib/components/molecules";
import type { SubCategoryInfo } from "$lib/modules/filesystem2";
import type { CategoryInfo } from "$lib/modules/filesystem";
import type { SelectedCategory } from "./service";
interface Props {
info: SubCategoryInfo;
info: Writable<CategoryInfo | null>;
menuIcon?: Component<SvelteHTMLElements["svg"]>;
onclick: (category: SelectedCategory) => void;
onMenuClick?: (category: SelectedCategory) => void;
@@ -16,25 +17,27 @@
let { info, menuIcon, onclick, onMenuClick }: Props = $props();
const openCategory = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ id, dataKey, dataKeyVersion, name });
};
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onMenuClick!({ id, dataKey, dataKeyVersion, name });
};
</script>
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openCategory}
actionButtonIcon={menuIcon}
onActionButtonClick={openMenu}
>
<CategoryLabel name={info.name} />
<CategoryLabel name={$info.name!} />
</ActionEntryButton>
{/if}

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules";
import type { CategoryInfo } from "$lib/modules/filesystem2";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import IconAddCircle from "~icons/material-symbols/add-circle";
@@ -25,6 +27,14 @@
subCategoryCreatePosition = "bottom",
subCategoryMenuIcon,
}: Props = $props();
let subCategories: Writable<CategoryInfo | null>[] = $state([]);
$effect(() => {
subCategories = info.subCategoryIds.map((id) =>
getCategoryInfo(id, $masterKeyStore?.get(1)?.key!),
);
});
</script>
<div class={["space-y-1", className]}>
@@ -45,7 +55,7 @@
{/if}
{#key info}
<Categories
categoryIds={info.subCategoryIds}
categories={subCategories}
categoryMenuIcon={subCategoryMenuIcon}
onCategoryClick={onSubCategoryClick}
onCategoryMenuClick={onSubCategoryMenuClick}

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { derived } from "svelte/store";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { CheckBox } from "$lib/components/atoms";
import { SubCategories, type SelectedCategory } from "$lib/components/molecules";
import { getFileInfo, type CategoryInfo } from "$lib/modules/filesystem2";
import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
@@ -18,7 +19,7 @@
onSubCategoryCreateClick: () => void;
onSubCategoryMenuClick: (subCategory: SelectedCategory) => void;
sortBy?: SortBy;
isFileRecursive?: boolean;
isFileRecursive: boolean;
}
let {
@@ -32,35 +33,39 @@
isFileRecursive = $bindable(),
}: Props = $props();
let fileInfos = $derived(
let files: { name?: string; info: Writable<FileInfo | null>; isRecursive: boolean }[] = $state(
[],
);
$effect(() => {
files =
info.files
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive)
.map(({ id, isRecursive }) => ({
info: getFileInfo(id, $masterKeyStore?.get(1)?.key!),
isRecursive,
})) ?? [],
);
let files = $derived(
derived(
fileInfos.map(({ info }) => info),
(infos) => {
const files = infos
.map(($info, i) => {
if ($info.status === "success") {
.map(({ id, isRecursive }) => {
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
return {
name: $info.data.name,
isRecursive: fileInfos[i]!.isRecursive,
info: $info.data,
name: get(info)?.name,
info,
isRecursive,
};
}
return undefined;
})
.filter((info) => info !== undefined);
}) ?? [];
const sort = () => {
sortEntries(files, sortBy);
return files;
},
),
};
return untrack(() => {
sort();
const unsubscribes = files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
<div class="space-y-4">
@@ -80,15 +85,13 @@
<div class="space-y-4 bg-white p-4">
<div class="flex items-center justify-between">
<p class="text-lg font-bold text-gray-800">파일</p>
{#if isFileRecursive !== undefined}
<CheckBox bind:checked={isFileRecursive}>
<p class="font-medium">하위 카테고리의 파일</p>
</CheckBox>
{/if}
</div>
<div class="space-y-1">
{#key info}
{#each $files as { info, isRecursive }}
{#each files as { info, isRecursive }}
<File
{info}
onclick={onFileClick}

View File

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

View File

@@ -1,24 +1,11 @@
interface FetchOptions {
fetch?: typeof fetch;
signal?: AbortSignal;
}
export const callGetApi = async (
input: RequestInfo,
{ fetch = globalThis.fetch, signal }: FetchOptions = {},
) => {
return await fetch(input, { method: "GET", signal });
export const callGetApi = async (input: RequestInfo, fetchInternal = fetch) => {
return await fetchInternal(input);
};
export const callPostApi = async <T>(
input: RequestInfo,
payload?: T,
{ fetch = globalThis.fetch, signal }: FetchOptions = {},
) => {
return await fetch(input, {
export const callPostApi = async <T>(input: RequestInfo, payload?: T, fetchInternal = fetch) => {
return await fetchInternal(input, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload ? JSON.stringify(payload) : undefined,
signal,
});
};

View File

@@ -62,10 +62,6 @@ export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
await filesystem.directory.put(directoryInfo);
};
export const updateDirectoryInfo = async (id: number, changes: { name?: string }) => {
await filesystem.directory.update(id, changes);
};
export const deleteDirectoryInfo = async (id: number) => {
await filesystem.directory.delete(id);
};
@@ -86,10 +82,6 @@ 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);
};
@@ -106,10 +98,7 @@ export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.put(categoryInfo);
};
export const updateCategoryInfo = async (
id: number,
changes: { name?: string; isFileRecursive?: boolean },
) => {
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
await filesystem.category.update(id, changes);
};

View File

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

View File

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

View File

@@ -1,294 +0,0 @@
import { useQueryClient, createQuery, createMutation } from "@tanstack/svelte-query";
import { callGetApi, callPostApi } from "$lib/hooks";
import {
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo,
deleteCategoryInfo,
type CategoryId,
} from "$lib/indexedDB";
import {
generateDataKey,
wrapDataKey,
unwrapDataKey,
encryptString,
decryptString,
} from "$lib/modules/crypto";
import type {
CategoryInfoResponse,
CategoryFileListResponse,
CategoryRenameRequest,
CategoryCreateRequest,
CategoryCreateResponse,
} from "$lib/server/schemas";
import type { MasterKey } from "$lib/stores";
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;
};
export type SubCategoryInfo = CategoryInfo & { id: number };
let temporaryIdCounter = -1;
const getInitialCategoryInfo = async (id: CategoryId) => {
const [category, subCategories] = await Promise.all([
id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined,
getCategoryInfosFromIndexedDB(id),
]);
const subCategoryIds = subCategories.map(({ id }) => id);
if (id === "root") {
return { id, subCategoryIds };
} else if (category) {
return {
id,
name: category.name,
subCategoryIds,
files: category.files,
isFileRecursive: category.isFileRecursive,
};
}
return undefined;
};
export const getCategoryInfo = (id: CategoryId, masterKey: CryptoKey) => {
return createQuery<CategoryInfo>({
queryKey: ["category", id],
queryFn: async ({ client, signal }) => {
if (!client.getQueryData<CategoryInfo>(["category", id])) {
const initialInfo = await getInitialCategoryInfo(id);
if (initialInfo) {
setTimeout(() => client.invalidateQueries({ queryKey: ["category", id] }), 0);
return initialInfo;
}
}
const res = await callGetApi(`/api/category/${id}`, { signal }); // TODO: 404
const { metadata, subCategories }: CategoryInfoResponse = await res.json();
if (id === "root") {
return { id, subCategoryIds: subCategories };
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
const res = await callGetApi(`/api/category/${id}/file/list?recurse=true`); // TODO: Error Handling
const { files }: CategoryFileListResponse = await res.json();
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
const prevInfo = client.getQueryData<CategoryInfo>(["category", id]);
await storeCategoryInfo({
id,
parentId: metadata!.parent,
name,
files: filesMapped,
isFileRecursive: prevInfo?.isFileRecursive ?? false,
});
return {
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subCategoryIds: subCategories,
files: filesMapped,
isFileRecursive: prevInfo?.isFileRecursive ?? false,
};
}
},
staleTime: Infinity,
});
};
export type CategoryInfoStore = ReturnType<typeof getCategoryInfo>;
export const useCategoryCreation = (parentId: CategoryId, masterKey: MasterKey) => {
const queryClient = useQueryClient();
return createMutation<void, Error, { name: string }, { tempId: number }>({
mutationFn: async ({ name }) => {
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
const res = await callPostApi<CategoryCreateRequest>("/api/category/create", {
parent: parentId,
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion.toISOString(),
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
});
if (!res.ok) throw new Error("Failed to create category");
const { category: id }: CategoryCreateResponse = await res.json();
queryClient.setQueryData<CategoryInfo>(["category", id], {
id,
name,
dataKey,
dataKeyVersion,
subCategoryIds: [],
files: [],
isFileRecursive: false,
});
await storeCategoryInfo({ id, parentId, name, files: [], isFileRecursive: false });
},
onMutate: async ({ name }) => {
const tempId = temporaryIdCounter--;
queryClient.setQueryData<CategoryInfo>(["category", tempId], {
id: tempId,
name,
subCategoryIds: [],
files: [],
isFileRecursive: false,
});
await queryClient.cancelQueries({ queryKey: ["category", parentId] });
queryClient.setQueryData<CategoryInfo>(["category", parentId], (prevParentInfo) => {
if (!prevParentInfo) return;
return {
...prevParentInfo,
subCategoryIds: [...prevParentInfo.subCategoryIds, tempId],
};
});
return { tempId };
},
onError: (_error, _variables, context) => {
if (context) {
queryClient.setQueryData<CategoryInfo>(["category", parentId], (prevParentInfo) => {
if (!prevParentInfo) return;
return {
...prevParentInfo,
subCategoryIds: prevParentInfo.subCategoryIds.filter((id) => id !== context.tempId),
};
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["category", parentId] });
},
});
};
export const useCategoryRename = () => {
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<CategoryRenameRequest>(`/api/category/${id}/rename`, {
dekVersion: dataKeyVersion.toISOString(),
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
if (!res.ok) throw new Error("Failed to rename category");
await updateCategoryInfo(id, { name: newName });
},
onMutate: async ({ id, newName }) => {
await queryClient.cancelQueries({ queryKey: ["category", id] });
const prevInfo = queryClient.getQueryData<SubCategoryInfo>(["category", id]);
if (prevInfo) {
queryClient.setQueryData<CategoryInfo>(["category", id], {
...prevInfo,
name: newName,
});
}
return { oldName: prevInfo?.name };
},
onError: (_error, { id }, context) => {
if (context?.oldName) {
queryClient.setQueryData<SubCategoryInfo>(["category", id], (prevInfo) => {
if (!prevInfo) return;
return { ...prevInfo, name: context.oldName! };
});
}
},
onSettled: (_data, _error, { id }) => {
queryClient.invalidateQueries({ queryKey: ["category", id] });
},
});
};
export const useCategoryDeletion = (parentId: CategoryId) => {
const queryClient = useQueryClient();
return createMutation<void, Error, { id: number }, {}>({
mutationFn: async ({ id }) => {
const res = await callPostApi(`/api/category/${id}/delete`);
if (!res.ok) throw new Error("Failed to delete category");
await deleteCategoryInfo(id);
// TODO: Update FileInfo
},
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ["category", parentId] });
queryClient.setQueryData<CategoryInfo>(["category", parentId], (prevParentInfo) => {
if (!prevParentInfo) return;
return {
...prevParentInfo,
subCategoryIds: prevParentInfo.subCategoryIds.filter((categoryId) => categoryId !== id),
};
});
return {};
},
onError: (_error, { id }, context) => {
if (context) {
queryClient.setQueryData<CategoryInfo>(["category", parentId], (prevParentInfo) => {
if (!prevParentInfo) return;
return {
...prevParentInfo,
subCategoryIds: [...prevParentInfo.subCategoryIds, id],
};
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["category", parentId] });
},
});
};
export const useCategoryFileRecursionToggle = () => {
const queryClient = useQueryClient();
return createMutation<void, Error, { id: number; isFileRecursive: boolean }, {}>({
mutationFn: async ({ id, isFileRecursive }) => {
await updateCategoryInfo(id, { isFileRecursive });
},
onMutate: async ({ id, isFileRecursive }) => {
const prevInfo = queryClient.getQueryData<SubCategoryInfo>(["category", id]);
if (prevInfo) {
queryClient.setQueryData<CategoryInfo>(["category", id], {
...prevInfo,
isFileRecursive,
});
}
},
});
};

View File

@@ -1,267 +0,0 @@
import { useQueryClient, createQuery, createMutation } from "@tanstack/svelte-query";
import { callGetApi, callPostApi } from "$lib/hooks";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
updateDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
deleteFileInfo,
type DirectoryId,
} from "$lib/indexedDB";
import {
generateDataKey,
wrapDataKey,
unwrapDataKey,
encryptString,
decryptString,
} from "$lib/modules/crypto";
import type {
DirectoryInfoResponse,
DirectoryDeleteResponse,
DirectoryRenameRequest,
DirectoryCreateRequest,
DirectoryCreateResponse,
} from "$lib/server/schemas";
import type { MasterKey } from "$lib/stores";
export type DirectoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export type SubDirectoryInfo = DirectoryInfo & { id: number };
let temporaryIdCounter = -1;
const getInitialDirectoryInfo = async (id: DirectoryId) => {
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
const subDirectoryIds = subDirectories.map(({ id }) => id);
const fileIds = files.map(({ id }) => id);
if (id === "root") {
return { id, subDirectoryIds, fileIds };
} else if (directory) {
return { id, name: directory.name, subDirectoryIds, fileIds };
}
return undefined;
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
return createQuery<DirectoryInfo>({
queryKey: ["directory", id],
queryFn: async ({ client, signal }) => {
if (!client.getQueryData(["directory", id])) {
const initialInfo = await getInitialDirectoryInfo(id);
if (initialInfo) {
setTimeout(() => client.invalidateQueries({ queryKey: ["directory", id] }), 0);
return initialInfo;
}
}
const res = await callGetApi(`/api/directory/${id}`, { signal }); // TODO: 404
const {
metadata,
subDirectories: subDirectoryIds,
files: fileIds,
}: DirectoryInfoResponse = await res.json();
if (id === "root") {
return { id, subDirectoryIds, fileIds };
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
return {
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
};
}
},
staleTime: Infinity,
});
};
export type DirectoryInfoStore = ReturnType<typeof getDirectoryInfo>;
export const useDirectoryCreation = (parentId: DirectoryId, masterKey: MasterKey) => {
const queryClient = useQueryClient();
return createMutation<void, Error, { name: string }, { tempId: number }>({
mutationFn: async ({ name }) => {
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
const res = await callPostApi<DirectoryCreateRequest>(`/api/directory/create`, {
parent: parentId,
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion.toISOString(),
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
});
if (!res.ok) throw new Error("Failed to create directory");
const { directory: id }: DirectoryCreateResponse = await res.json();
queryClient.setQueryData<DirectoryInfo>(["directory", id], {
id,
name,
dataKey,
dataKeyVersion,
subDirectoryIds: [],
fileIds: [],
});
await storeDirectoryInfo({ id, parentId, name });
},
onMutate: async ({ name }) => {
const tempId = temporaryIdCounter--;
queryClient.setQueryData<DirectoryInfo>(["directory", tempId], {
id: tempId,
name,
subDirectoryIds: [],
fileIds: [],
});
await queryClient.cancelQueries({ queryKey: ["directory", parentId] });
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: [...prevParentInfo.subDirectoryIds, tempId],
};
});
return { tempId };
},
onError: (_error, _variables, context) => {
if (context) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: prevParentInfo.subDirectoryIds.filter((id) => id !== context.tempId),
};
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["directory", parentId] });
},
});
};
export const useDirectoryRename = () => {
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<DirectoryRenameRequest>(`/api/directory/${id}/rename`, {
dekVersion: dataKeyVersion.toISOString(),
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
if (!res.ok) throw new Error("Failed to rename directory");
await updateDirectoryInfo(id, { name: newName });
},
onMutate: async ({ id, newName }) => {
await queryClient.cancelQueries({ queryKey: ["directory", id] });
const prevInfo = queryClient.getQueryData<SubDirectoryInfo>(["directory", id]);
if (prevInfo) {
queryClient.setQueryData<DirectoryInfo>(["directory", id], {
...prevInfo,
name: newName,
});
}
return { oldName: prevInfo?.name };
},
onError: (_error, { id }, context) => {
if (context?.oldName) {
queryClient.setQueryData<SubDirectoryInfo>(["directory", id], (prevInfo) => {
if (!prevInfo) return undefined;
return { ...prevInfo, name: context.oldName! };
});
}
},
onSettled: (_data, _error, { id }) => {
queryClient.invalidateQueries({ queryKey: ["directory", id] });
},
});
};
export const useDirectoryDeletion = (parentId: DirectoryId) => {
const queryClient = useQueryClient();
return createMutation<{ deletedFiles: number[] }, Error, { id: number }, {}>({
mutationFn: async ({ id }) => {
const res = await callPostApi(`/api/directory/${id}/delete`);
if (!res.ok) throw new Error("Failed to delete directory");
const { deletedDirectories, deletedFiles }: DirectoryDeleteResponse = await res.json();
await Promise.all([
...deletedDirectories.map(deleteDirectoryInfo),
...deletedFiles.map(deleteFileInfo),
]);
return { deletedFiles };
},
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ["directory", parentId] });
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: prevParentInfo.subDirectoryIds.filter(
(subDirectoryId) => subDirectoryId !== id,
),
};
});
return {};
},
onError: (_error, { id }, context) => {
if (context) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: [...prevParentInfo.subDirectoryIds, id],
};
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["directory", parentId] });
},
});
};

View File

@@ -1,230 +0,0 @@
import { useQueryClient, createQuery, createMutation } from "@tanstack/svelte-query";
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 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) => {
return createQuery<FileInfo>({
queryKey: ["file", id],
queryFn: async ({ client, signal }) => {
if (!client.getQueryData(["file", id])) {
const initialInfo = await getFileInfoFromIndexedDB(id);
if (initialInfo) {
setTimeout(() => client.invalidateQueries({ queryKey: ["file", id] }), 0);
return initialInfo;
}
}
const res = await callGetApi(`/api/file/${id}`, { signal }); // 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,
};
},
staleTime: Infinity,
});
};
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

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

View File

@@ -67,15 +67,10 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
return new Promise<Blob>((resolve, reject) => {
const video = document.createElement("video");
video.onloadedmetadata = () => {
if (video.videoWidth === 0 || video.videoHeight === 0) {
return reject();
}
const callbackId = video.requestVideoFrameCallback(() => {
captureVideoThumbnail(video).then(resolve).catch(reject);
video.cancelVideoFrameCallback(callbackId);
});
video.currentTime = Math.min(time, video.duration);
video.requestVideoFrameCallback(() => {
captureVideoThumbnail(video).then(resolve).catch(reject);
});
};
video.onerror = reject;

View File

@@ -17,7 +17,7 @@ interface Category {
export type NewCategory = Omit<Category, "id">;
export const registerCategory = async (params: NewCategory) => {
return await db.transaction().execute(async (trx) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
@@ -51,7 +51,6 @@ export const registerCategory = async (params: NewCategory) => {
new_name: params.encName,
})
.execute();
return { id: categoryId };
});
};

View File

@@ -39,7 +39,7 @@ interface File {
export type NewFile = Omit<File, "id">;
export const registerDirectory = async (params: NewDirectory) => {
return await db.transaction().execute(async (trx) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
@@ -73,7 +73,6 @@ export const registerDirectory = async (params: NewDirectory) => {
new_name: params.encName,
})
.execute();
return { id: directoryId };
});
};
@@ -181,10 +180,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
};
const unregisterDirectoryRecursively = async (
directoryId: number,
): Promise<{
subDirectories: { id: number }[];
files: { id: number; path: string; thumbnailPath: string | null }[];
}> => {
): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => {
const files = await unregisterFiles(directoryId);
const subDirectories = await trx
.selectFrom("directory")
@@ -192,7 +188,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
.where("parent_id", "=", directoryId)
.where("user_id", "=", userId)
.execute();
const subDirectoryEntries = await Promise.all(
const subDirectoryFilePaths = await Promise.all(
subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
);
@@ -204,12 +200,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
if (deleteRes.numDeletedRows === 0n) {
throw new IntegrityError("Directory not found");
}
return {
subDirectories: subDirectoryEntries
.flatMap(({ subDirectories }) => subDirectories)
.concat(subDirectories),
files: subDirectoryEntries.flatMap(({ files }) => files).concat(files),
};
return files.concat(...subDirectoryFilePaths);
};
return await unregisterDirectoryRecursively(directoryId);
});

View File

@@ -53,8 +53,3 @@ export const categoryCreateRequest = z.object({
nameIv: z.string().base64().nonempty(),
});
export type CategoryCreateRequest = z.input<typeof categoryCreateRequest>;
export const categoryCreateResponse = z.object({
category: z.number().int().positive(),
});
export type CategoryCreateResponse = z.output<typeof categoryCreateResponse>;

View File

@@ -19,7 +19,6 @@ export const directoryInfoResponse = z.object({
export type DirectoryInfoResponse = z.output<typeof directoryInfoResponse>;
export const directoryDeleteResponse = z.object({
deletedDirectories: z.number().int().positive().array(),
deletedFiles: z.number().int().positive().array(),
});
export type DirectoryDeleteResponse = z.output<typeof directoryDeleteResponse>;
@@ -40,8 +39,3 @@ export const directoryCreateRequest = z.object({
nameIv: z.string().base64().nonempty(),
});
export type DirectoryCreateRequest = z.input<typeof directoryCreateRequest>;
export const directoryCreateResponse = z.object({
directory: z.number().int().positive(),
});
export type DirectoryCreateResponse = z.output<typeof directoryCreateResponse>;

View File

@@ -123,8 +123,7 @@ export const createCategory = async (params: NewCategory) => {
}
try {
const { id } = await registerCategory(params);
return { id };
await registerCategory(params);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Inactive MEK version");

View File

@@ -42,9 +42,8 @@ const safeUnlink = async (path: string | null) => {
export const deleteDirectory = async (userId: number, directoryId: number) => {
try {
const { subDirectories, files } = await unregisterDirectory(userId, directoryId);
const files = await unregisterDirectory(userId, directoryId);
return {
directories: [...subDirectories.map(({ id }) => id), directoryId],
files: files.map(({ id, path, thumbnailPath }) => {
safeUnlink(path); // Intended
safeUnlink(thumbnailPath); // Intended
@@ -87,8 +86,7 @@ export const createDirectory = async (params: NewDirectory) => {
}
try {
const { id } = await registerDirectory(params);
return { id };
await registerDirectory(params);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Invalid MEK version");

View File

@@ -5,8 +5,12 @@
import { goto } from "$app/navigation";
import { FullscreenDiv } from "$lib/components/atoms";
import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem2";
import { getFileInfo } from "$lib/modules/filesystem2";
import {
getFileInfo,
getCategoryInfo,
type FileInfo,
type CategoryInfo,
} from "$lib/modules/filesystem";
import { captureVideoThumbnail } from "$lib/modules/thumbnail";
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
@@ -24,7 +28,8 @@
let { data } = $props();
let info = $derived(getFileInfo(data.id, $masterKeyStore?.get(1)?.key!));
let info: Writable<FileInfo | null> | undefined = $state();
let categories: Writable<CategoryInfo | null>[] = $state([]);
let isAddToCategoryBottomSheetOpen = $state(false);
@@ -80,14 +85,19 @@
};
$effect(() => {
data.id;
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
isDownloadRequested = false;
viewerType = undefined;
});
$effect(() => {
if ($info.data?.dataKey && $info.data?.contentIv) {
const contentType = $info.data.contentType;
categories =
$info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? [];
});
$effect(() => {
if ($info && $info.dataKey && $info.contentIv) {
const contentType = $info.contentType;
if (contentType.startsWith("image")) {
viewerType = "image";
} else if (contentType.startsWith("video")) {
@@ -97,23 +107,21 @@
untrack(() => {
if (!downloadStatus && !isDownloadRequested) {
isDownloadRequested = true;
requestFileDownload(data.id, $info.data.contentIv!, $info.data.dataKey!).then(
async (buffer) => {
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => {
const blob = await updateViewer(buffer, contentType);
if (!viewerType) {
FileSaver.saveAs(blob, $info.data.name);
FileSaver.saveAs(blob, $info.name);
}
},
);
});
}
});
}
});
$effect(() => {
if ($info.status === "success" && $downloadStatus?.status === "decrypted") {
if ($info && $downloadStatus?.status === "decrypted") {
untrack(
() => !isDownloadRequested && updateViewer($downloadStatus.result!, $info.data.contentType),
() => !isDownloadRequested && updateViewer($downloadStatus.result!, $info.contentType),
);
}
});
@@ -125,11 +133,11 @@
<title>파일</title>
</svelte:head>
<TopBar title={$info.data?.name} />
<TopBar title={$info?.name} />
<FullscreenDiv>
<div class="space-y-4 pb-4">
<DownloadStatus status={downloadStatus} />
{#if $info.status === "success" && viewerType}
{#if $info && viewerType}
<div class="flex w-full justify-center">
{#snippet viewerLoading(message: string)}
<p class="text-gray-500">{message}</p>
@@ -137,7 +145,7 @@
{#if viewerType === "image"}
{#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.data.name} onerror={convertHeicToJpeg} />
<img src={fileBlobUrl} alt={$info.name} onerror={convertHeicToJpeg} />
{:else}
{@render viewerLoading("이미지를 불러오고 있어요.")}
{/if}
@@ -148,7 +156,7 @@
<video bind:this={videoElement} src={fileBlobUrl} controls muted></video>
<IconEntryButton
icon={IconCamera}
onclick={() => updateThumbnail($info.data.dataKey!, $info.data.dataKeyVersion!)}
onclick={() => updateThumbnail($info.dataKey!, $info.dataKeyVersion!)}
class="w-full"
>
이 장면을 썸네일로 설정하기
@@ -164,7 +172,7 @@
<p class="text-lg font-bold">카테고리</p>
<div class="space-y-1">
<Categories
categoryIds={$info.data?.categoryIds ?? []}
{categories}
categoryMenuIcon={IconClose}
onCategoryClick={({ id }) => goto(`/category/${id}`)}
onCategoryMenuClick={({ id }) => removeFromCategory(id)}

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "$lib/components/atoms";
import { SubCategories } from "$lib/components/molecules";
import { CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, type CategoryInfoStore } from "$lib/modules/filesystem2";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { requestCategoryCreation } from "./service";
@@ -13,7 +14,7 @@
let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props();
let category: CategoryInfoStore | undefined = $state();
let category: Writable<CategoryInfo | null> | undefined = $state();
let isCategoryCreateModalOpen = $state(false);
@@ -24,20 +25,20 @@
});
</script>
{#if $category?.status === "success"}
{#if $category}
<BottomSheet bind:isOpen class="flex flex-col">
<FullscreenDiv>
<SubCategories
class="py-4"
info={$category.data}
info={$category}
onSubCategoryClick={({ id }) =>
(category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
subCategoryCreatePosition="top"
/>
{#if $category.data.id !== "root"}
{#if $category.id !== "root"}
<BottomDiv>
<Button onclick={() => onAddToCategoryClick($category.data.id as number)} class="w-full">
<Button onclick={() => onAddToCategoryClick($category.id)} class="w-full">
이 카테고리에 추가하기
</Button>
</BottomDiv>
@@ -49,8 +50,8 @@
<CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, $category!.data!.id, $masterKeyStore?.get(1)!)) {
category = getCategoryInfo($category!.data!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
if (await requestCategoryCreation(name, $category!.id, $masterKeyStore?.get(1)!)) {
category = getCategoryInfo($category!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { get, type Writable } from "svelte/store";
import { getFileInfo } from "$lib/modules/filesystem2";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { formatNetworkSpeed } from "$lib/modules/util";
import { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
@@ -17,10 +17,14 @@
let { status }: Props = $props();
let fileInfo = $derived(getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!));
let fileInfo: Writable<FileInfo | null> | undefined = $state();
$effect(() => {
fileInfo = getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!);
});
</script>
{#if $fileInfo.status === "success"}
{#if $fileInfo}
<div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg text-gray-600">
{#if $status.status === "download-pending"}
@@ -38,8 +42,8 @@
{/if}
</div>
<div class="flex-grow overflow-hidden">
<p title={$fileInfo.data.name} class="truncate font-medium">
{$fileInfo.data.name}
<p title={$fileInfo.name} class="truncate font-medium">
{$fileInfo.name}
</p>
<p class="text-xs text-gray-800">
{#if $status.status === "download-pending"}

View File

@@ -1,17 +1,18 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import type { FileCacheIndex } from "$lib/indexedDB";
import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
import { getFileInfo, type FileInfoStore } from "$lib/modules/filesystem2";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { formatFileSize } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
interface FileCache {
index: FileCacheIndex;
fileInfo: FileInfoStore;
fileInfo: Writable<FileInfo | null>;
}
let fileCache: FileCache[] | undefined = $state();

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileCacheIndex } from "$lib/indexedDB";
import type { FileInfoStore } from "$lib/modules/filesystem2";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatFileSize } from "$lib/modules/util";
import IconDraft from "~icons/material-symbols/draft";
@@ -9,7 +10,7 @@
interface Props {
index: FileCacheIndex;
info: FileInfoStore;
info: Writable<FileInfo | null>;
onDeleteClick: (fileId: number) => void;
}
@@ -27,8 +28,8 @@
</div>
{/if}
<div class="flex-grow overflow-hidden">
{#if $info.status === "success"}
<p title={$info.data.name} class="truncate font-medium">{$info.data.name}</p>
{#if $info}
<p title={$info.name} class="truncate font-medium">{$info.name}</p>
{:else}
<p class="font-medium">삭제된 파일</p>
{/if}

View File

@@ -5,7 +5,7 @@
import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms";
import { IconEntryButton, TopBar } from "$lib/components/molecules";
import { deleteAllFileThumbnailCaches } from "$lib/modules/file";
import { getFileInfo } from "$lib/modules/filesystem2";
import { getFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
import {
@@ -21,8 +21,8 @@
const generateAllThumbnails = () => {
persistentStates.files.forEach(({ info }) => {
const fileInfo = get(info);
if (fileInfo.data) {
requestThumbnailGeneration(fileInfo.data);
if (fileInfo) {
requestThumbnailGeneration(fileInfo);
}
});
};

View File

@@ -4,7 +4,7 @@ import type { MissingThumbnailFileScanResponse } from "$lib/server/schemas";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const res = await callPostApi("/api/file/scanMissingThumbnails", undefined, { fetch });
const res = await callPostApi("/api/file/scanMissingThumbnails", undefined, fetch);
if (!res.ok) {
error(500, "Internal server error");
}

View File

@@ -13,14 +13,14 @@
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo, FileInfoStore } from "$lib/modules/filesystem2";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/modules/util";
import type { GenerationStatus } from "./service.svelte";
import IconCamera from "~icons/material-symbols/camera";
interface Props {
info: FileInfoStore;
info: Writable<FileInfo | null>;
onclick: (selectedFile: FileInfo) => void;
onGenerateThumbnailClick: (selectedFile: FileInfo) => void;
generationStatus?: Writable<GenerationStatus>;
@@ -29,18 +29,18 @@
let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props();
</script>
{#if $info.status === "success"}
{#if $info}
<ActionEntryButton
class="h-14"
onclick={() => onclick($info.data)}
onclick={() => onclick($info)}
actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined}
onActionButtonClick={() => onGenerateThumbnailClick($info.data)}
onActionButtonClick={() => onGenerateThumbnailClick($info)}
actionButtonClass="text-gray-800"
>
{@const subtext =
$generationStatus && $generationStatus !== "uploaded"
? subtexts[$generationStatus]
: formatDateTime($info.data.createdAt ?? $info.data.lastModifiedAt)}
<DirectoryEntryLabel type="file" name={$info.data.name} {subtext} />
: formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
<DirectoryEntryLabel type="file" name={$info.name} {subtext} />
</ActionEntryButton>
{/if}

View File

@@ -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, FileInfoStore } from "$lib/modules/filesystem2";
import type { FileInfo } 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: FileInfoStore;
info: Writable<FileInfo | null>;
status?: Writable<GenerationStatus>;
}

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components/molecules";
import { Category, CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, useCategoryFileRecursionToggle } from "$lib/modules/filesystem2";
import { getCategoryInfo, updateCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
@@ -18,9 +19,9 @@
let { data } = $props();
let context = createContext();
let info = $derived(getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!));
let toggleFileRecursion = useCategoryFileRecursionToggle();
let isFileRecursive = $derived($info.data?.isFileRecursive);
let info: Writable<CategoryInfo | null> | undefined = $state();
let isFileRecursive: boolean | undefined = $state();
let isCategoryCreateModalOpen = $state(false);
let isCategoryMenuBottomSheetOpen = $state(false);
@@ -28,8 +29,19 @@
let isCategoryDeleteModalOpen = $state(false);
$effect(() => {
if (isFileRecursive !== undefined && $info.data?.isFileRecursive !== isFileRecursive) {
$toggleFileRecursion.mutate({ id: data.id as number, isFileRecursive });
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
isFileRecursive = undefined;
});
$effect(() => {
if ($info && isFileRecursive === undefined) {
isFileRecursive = $info.isFileRecursive ?? false;
}
});
$effect(() => {
if (data.id !== "root" && $info?.isFileRecursive !== isFileRecursive) {
updateCategoryInfo(data.id as number, { isFileRecursive });
}
});
</script>
@@ -39,13 +51,13 @@
</svelte:head>
{#if data.id !== "root"}
<TopBar title={$info.data?.name} />
<TopBar title={$info?.name} />
{/if}
<div class="min-h-full bg-gray-100 pb-[5.5em]">
{#if $info.status === "success"}
{#if $info && isFileRecursive !== undefined}
<Category
bind:isFileRecursive
info={$info.data}
info={$info}
onFileClick={({ id }) => goto(`/file/${id}`)}
onFileRemoveClick={async ({ id }) => {
await requestFileRemovalFromCategory(id, data.id as number);

View File

@@ -1,23 +1,10 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { FloatingButton } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import {
storeFileCache,
deleteFileCache,
storeFileThumbnailCache,
deleteFileThumbnailCache,
} from "$lib/modules/file";
import {
getDirectoryInfo,
useDirectoryCreation,
useDirectoryRename,
useDirectoryDeletion,
useFileUpload,
useFileRename,
useFileDeletion,
} from "$lib/modules/filesystem2";
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries";
@@ -28,23 +15,21 @@
import EntryMenuBottomSheet from "./EntryMenuBottomSheet.svelte";
import EntryRenameModal from "./EntryRenameModal.svelte";
import UploadStatusCard from "./UploadStatusCard.svelte";
import { createContext, requestHmacSecretDownload } from "./service.svelte";
import {
createContext,
requestHmacSecretDownload,
requestDirectoryCreation,
requestFileUpload,
requestEntryRename,
requestEntryDeletion,
} from "./service.svelte";
import IconAdd from "~icons/material-symbols/add";
let { data } = $props();
let context = createContext();
let info = $derived(getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!));
let requestDirectoryCreation = $derived(useDirectoryCreation(data.id, $masterKeyStore?.get(1)!));
let requestDirectoryRename = useDirectoryRename();
let requestDirectoryDeletion = $derived(useDirectoryDeletion(data.id));
let requestFileUpload = $derived(
useFileUpload(data.id, $masterKeyStore?.get(1)!, $hmacSecretStore?.get(1)!),
);
let requestFileRename = $derived(useFileRename());
let requestFileDeletion = $derived(useFileDeletion(data.id));
let info: Writable<DirectoryInfo | null> | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state();
let duplicatedFile: File | undefined = $state();
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
@@ -62,24 +47,21 @@
if (!files || files.length === 0) return;
for (const file of files) {
$requestFileUpload
.mutateAsync({
file,
onDuplicate: () => {
requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => {
return new Promise((resolve) => {
duplicatedFile = file;
resolveForDuplicateFileModal = resolve;
isDuplicateFileModalOpen = true;
});
},
})
.then((res) => {
if (res) {
storeFileCache(res.fileId, res.fileBuffer); // Intended
if (res.thumbnailBuffer) {
storeFileThumbnailCache(res.fileId, res.thumbnailBuffer); // Intended
}
}
if (!res) return;
// TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
})
.catch((e: Error) => {
// TODO: FIXME
console.error(e);
});
}
@@ -91,6 +73,10 @@
throw new Error("Failed to download hmac secrets");
}
});
$effect(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
</script>
<svelte:head>
@@ -101,17 +87,17 @@
<div class="flex h-full flex-col">
{#if data.id !== "root"}
<TopBar title={$info.data?.name} class="flex-shrink-0" />
<TopBar title={$info?.name} class="flex-shrink-0" />
{/if}
{#if $info.status === "success"}
{#if $info}
<div class={["flex flex-grow flex-col px-4 pb-4", data.id === "root" && "pt-4"]}>
<div class="flex gap-x-2">
<UploadStatusCard onclick={() => goto("/file/uploads")} />
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
</div>
{#key $info.data.id}
{#key $info}
<DirectoryEntries
info={$info.data}
info={$info}
onEntryClick={({ type, id }) => goto(`/${type}/${id}`)}
onEntryMenuClick={(entry) => {
context.selectedEntry = entry;
@@ -144,8 +130,11 @@
<DirectoryCreateModal
bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => {
$requestDirectoryCreation.mutate({ name });
return true; // TODO
if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<DuplicateFileModal
@@ -175,45 +164,20 @@
<EntryRenameModal
bind:isOpen={isEntryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (context.selectedEntry!.type === "directory") {
$requestDirectoryRename.mutate({
id: context.selectedEntry!.id,
dataKey: context.selectedEntry!.dataKey,
dataKeyVersion: context.selectedEntry!.dataKeyVersion,
newName,
});
return true; // TODO
} else {
$requestFileRename.mutate({
id: context.selectedEntry!.id,
dataKey: context.selectedEntry!.dataKey,
dataKeyVersion: context.selectedEntry!.dataKeyVersion,
newName,
});
return true; // TODO
if (await requestEntryRename(context.selectedEntry!, newName)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<EntryDeleteModal
bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => {
if (context.selectedEntry!.type === "directory") {
const res = await $requestDirectoryDeletion.mutateAsync({ id: context.selectedEntry!.id });
if (!res) return false;
await Promise.all(
res.deletedFiles.flatMap((fileId) => [
deleteFileCache(fileId),
deleteFileThumbnailCache(fileId),
]),
);
return true; // TODO
} else {
await $requestFileDeletion.mutateAsync({ id: context.selectedEntry!.id });
await Promise.all([
deleteFileCache(context.selectedEntry!.id),
deleteFileThumbnailCache(context.selectedEntry!.id),
]);
return true; // TODO
if (await requestEntryDeletion(context.selectedEntry!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { derived } from "svelte/store";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import {
getDirectoryInfo,
getFileInfo,
type DirectoryInfo,
type SubDirectoryInfo,
type FileInfo,
} from "$lib/modules/filesystem2";
} from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import {
fileUploadStatusStore,
@@ -30,84 +30,88 @@
interface DirectoryEntry {
name?: string;
info: SubDirectoryInfo;
info: Writable<DirectoryInfo | null>;
}
type FileEntry =
| {
type: "file";
name?: string;
info: FileInfo;
info: Writable<FileInfo | null>;
}
| {
type: "uploading-file";
name: string;
info: FileUploadStatus;
info: Writable<FileUploadStatus>;
};
let subDirectories = $derived(
derived(
info.subDirectoryIds.map((id) => getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!)),
(infos) => {
const subDirectories = infos
.filter(($info) => $info.status === "success")
.map(
($info) =>
({
name: $info.data.name,
info: $info.data as SubDirectoryInfo,
}) satisfies DirectoryEntry,
);
sortEntries(subDirectories, sortBy);
return subDirectories;
},
),
);
let files = $derived(
derived(
info.fileIds.map((id) => getFileInfo(id, $masterKeyStore?.get(1)?.key!)),
(infos) =>
infos
.filter(($info) => $info.status === "success")
.map(
($info) =>
({
let subDirectories: DirectoryEntry[] = $state([]);
let files: FileEntry[] = $state([]);
$effect(() => {
// TODO: Fix duplicated requests
subDirectories = info.subDirectoryIds.map((id) => {
const info = getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!);
return { name: get(info)?.name, info };
});
files = info.fileIds
.map((id): FileEntry => {
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
return {
type: "file",
name: $info.data.name,
info: $info.data,
}) satisfies FileEntry,
),
),
);
let uploadingFiles = $derived(
derived($fileUploadStatusStore, (statuses) =>
statuses
.filter(({ parentId, status }) => parentId === info.id && isFileUploading(status))
.map(
($status) =>
({
name: get(info)?.name,
info,
};
})
.concat(
$fileUploadStatusStore
.filter((statusStore) => {
const { parentId, status } = get(statusStore);
return parentId === info.id && isFileUploading(status);
})
.map((status) => ({
type: "uploading-file",
name: $status.name,
info: $status,
}) satisfies FileEntry,
),
),
name: get(status).name,
info: status,
})),
);
let everyFiles = $derived(
derived([files, uploadingFiles], ([$files, $uploadingFiles]) => {
const allFiles = [...$files, ...$uploadingFiles];
sortEntries(allFiles, sortBy);
return allFiles;
const sort = () => {
sortEntries(subDirectories, sortBy);
sortEntries(files, sortBy);
};
return untrack(() => {
sort();
const unsubscribes = subDirectories
.map((subDirectory) =>
subDirectory.info.subscribe((value) => {
if (subDirectory.name === value?.name) return;
subDirectory.name = value?.name;
sort();
}),
)
.concat(
files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
{#if $subDirectories.length + $everyFiles.length > 0}
{#if subDirectories.length + files.length > 0}
<div class="space-y-1 pb-[4.5rem]">
{#each $subDirectories as { info }}
{#each subDirectories as { info }}
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{/each}
{#each $everyFiles as file}
{#each files as file}
{#if file.type === "file"}
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else}

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem2";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/modules/util";
import { requestFileThumbnailDownload } from "./service";
import type { SelectedEntry } from "../service.svelte";
@@ -9,7 +10,7 @@
import IconMoreVert from "~icons/material-symbols/more-vert";
interface Props {
info: FileInfo;
info: Writable<FileInfo | null>;
onclick: (selectedEntry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void;
}
@@ -19,22 +20,22 @@
let thumbnail: string | undefined = $state();
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info!;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
};
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info!;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if (info.dataKey) {
requestFileThumbnailDownload(info.id, info.dataKey)
if ($info?.dataKey) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
@@ -48,6 +49,7 @@
});
</script>
{#if $info}
<ActionEntryButton
class="h-14"
onclick={openFile}
@@ -57,7 +59,8 @@
<DirectoryEntryLabel
type="file"
{thumbnail}
name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
name={$info.name}
subtext={formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
/>
</ActionEntryButton>
{/if}

View File

@@ -1,13 +1,16 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { SubDirectoryInfo } from "$lib/modules/filesystem2";
import type { DirectoryInfo } from "$lib/modules/filesystem";
import type { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert";
type SubDirectoryInfo = DirectoryInfo & { id: number };
interface Props {
info: SubDirectoryInfo;
info: Writable<DirectoryInfo | null>;
onclick: (selectedEntry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void;
}
@@ -15,25 +18,27 @@
let { info, onclick, onOpenMenuClick }: Props = $props();
const openDirectory = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
};
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
};
</script>
{#if $info}
<ActionEntryButton
class="h-14"
onclick={openDirectory}
actionButtonIcon={IconMoreVert}
onActionButtonClick={openMenu}
>
<DirectoryEntryLabel type="directory" name={info.name} />
<DirectoryEntryLabel type="directory" name={$info.name!} />
</ActionEntryButton>
{/if}

View File

@@ -1,35 +1,36 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import { isFileUploading, type FileUploadStatus } from "$lib/stores";
import IconDraft from "~icons/material-symbols/draft";
interface Props {
status: FileUploadStatus;
status: Writable<FileUploadStatus>;
}
let { status }: Props = $props();
</script>
{#if isFileUploading(status.status)}
{#if isFileUploading($status.status)}
<div class="flex h-14 gap-x-4 p-2">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center text-xl">
<IconDraft class="text-gray-600" />
</div>
<div class="flex flex-grow flex-col overflow-hidden text-gray-800">
<p title={status.name} class="truncate font-medium">
{status.name}
<p title={$status.name} class="truncate font-medium">
{$status.name}
</p>
<p class="text-xs">
{#if status.status === "encryption-pending"}
{#if $status.status === "encryption-pending"}
준비 중
{:else if status.status === "encrypting"}
{:else if $status.status === "encrypting"}
암호화하는 중
{:else if status.status === "upload-pending"}
{:else if $status.status === "upload-pending"}
업로드를 기다리는 중
{:else if status.status === "uploading"}
전송됨 {Math.floor((status.progress ?? 0) * 100)}% ·
{formatNetworkSpeed((status.rate ?? 0) * 8)}
{:else if $status.status === "uploading"}
전송됨 {Math.floor(($status.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
{/if}
</p>
</div>

View File

@@ -4,7 +4,7 @@ import type { UserInfoResponse } from "$lib/server/schemas";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const res = await callGetApi("/api/user", { fetch });
const res = await callGetApi("/api/user", fetch);
if (!res.ok) {
error(500, "Internal server error");
}

View File

@@ -1,8 +1,6 @@
<script lang="ts">
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
import { onMount } from "svelte";
import { get } from "svelte/store";
import { browser } from "$app/environment";
import { goto as svelteGoto } from "$app/navigation";
import {
fileUploadStatusStore,
@@ -12,19 +10,10 @@
clientKeyStore,
masterKeyStore,
} from "$lib/stores";
import "../app.css";
let { children } = $props();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
enabled: browser,
},
},
});
const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => {
if (
$fileUploadStatusStore.some((status) => isFileUploading(get(status).status)) ||
@@ -35,8 +24,6 @@
};
onMount(async () => {
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
const goto = async (url: string) => {
const whitelist = ["/auth/login", "/key", "/client/pending"];
if (!whitelist.some((path) => location.pathname.startsWith(path))) {
@@ -56,6 +43,4 @@
<svelte:window onbeforeunload={protectFileUploadAndDownload} />
<QueryClientProvider client={queryClient}>
{@render children()}
</QueryClientProvider>

View File

@@ -1,10 +1,6 @@
import { error, json } from "@sveltejs/kit";
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import {
categoryCreateRequest,
categoryCreateResponse,
type CategoryCreateResponse,
} from "$lib/server/schemas";
import { categoryCreateRequest } from "$lib/server/schemas";
import { createCategory } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
@@ -15,7 +11,7 @@ export const POST: RequestHandler = async ({ locals, request }) => {
if (!zodRes.success) error(400, "Invalid request body");
const { parent, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
const { id } = await createCategory({
await createCategory({
userId,
parentId: parent,
mekVersion,
@@ -23,5 +19,5 @@ export const POST: RequestHandler = async ({ locals, request }) => {
dekVersion: new Date(dekVersion),
encName: { ciphertext: name, iv: nameIv },
});
return json(categoryCreateResponse.parse({ category: id } satisfies CategoryCreateResponse));
return text("Category created", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -16,11 +16,8 @@ export const POST: RequestHandler = async ({ locals, params }) => {
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
const { directories, files } = await deleteDirectory(userId, id);
const { files } = await deleteDirectory(userId, id);
return json(
directoryDeleteResponse.parse({
deletedDirectories: directories,
deletedFiles: files,
} satisfies DirectoryDeleteResponse),
directoryDeleteResponse.parse({ deletedFiles: files } satisfies DirectoryDeleteResponse),
);
};

View File

@@ -1,10 +1,6 @@
import { error, json } from "@sveltejs/kit";
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import {
directoryCreateRequest,
directoryCreateResponse,
type DirectoryCreateResponse,
} from "$lib/server/schemas";
import { directoryCreateRequest } from "$lib/server/schemas";
import { createDirectory } from "$lib/server/services/directory";
import type { RequestHandler } from "./$types";
@@ -15,7 +11,7 @@ export const POST: RequestHandler = async ({ locals, request }) => {
if (!zodRes.success) error(400, "Invalid request body");
const { parent, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
const { id } = await createDirectory({
await createDirectory({
userId,
parentId: parent,
mekVersion,
@@ -23,5 +19,5 @@ export const POST: RequestHandler = async ({ locals, request }) => {
dekVersion: new Date(dekVersion),
encName: { ciphertext: name, iv: nameIv },
});
return json(directoryCreateResponse.parse({ directory: id } satisfies DirectoryCreateResponse));
return text("Directory created", { headers: { "Content-Type": "text/plain" } });
};