1 Commits

Author SHA1 Message Date
static
f059f0d7c4 tRPC Endpoint를 /api/trpc로 변경 2025-12-25 20:13:44 +09:00
145 changed files with 4043 additions and 3948 deletions

View File

@@ -1,24 +1,21 @@
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
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";
import svelteConfig from "./svelte.config.js";
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default defineConfig(
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
...svelte.configs["flat/recommended"],
...tailwind.configs["flat/recommended"],
prettier,
...svelte.configs.prettier,
...svelte.configs["flat/prettier"],
{
languageOptions: {
globals: {
@@ -26,18 +23,13 @@ export default defineConfig(
...globals.node,
},
},
rules: {
"no-undef": "off",
},
},
{
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
parser: ts.parser,
svelteConfig,
},
},
},

View File

@@ -1,7 +1,7 @@
{
"name": "arkvault",
"private": true,
"version": "0.7.0",
"version": "0.5.1",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -16,55 +16,52 @@
"db:migrate": "kysely migrate"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^9.39.2",
"@iconify-json/material-symbols": "^1.2.50",
"@eslint/compat": "^1.4.1",
"@iconify-json/material-symbols": "^1.2.44",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/kit": "^2.48.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tanstack/svelte-virtual": "^3.13.16",
"@trpc/client": "^11.8.1",
"@trpc/client": "^11.7.1",
"@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.8",
"@types/pg": "^8.16.0",
"autoprefixer": "^10.4.23",
"axios": "^1.13.2",
"@types/pg": "^8.15.6",
"autoprefixer": "^10.4.21",
"axios": "^1.13.1",
"dexie": "^4.2.1",
"eslint": "^9.39.2",
"eslint": "^9.39.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.1",
"eslint-plugin-svelte": "^3.13.0",
"eslint-plugin-tailwindcss": "^3.18.2",
"exifreader": "^4.33.1",
"exifreader": "^4.32.0",
"file-saver": "^2.0.5",
"globals": "^16.5.0",
"heic2any": "^0.0.4",
"kysely-ctl": "^0.19.0",
"lru-cache": "^11.2.4",
"lru-cache": "^11.2.2",
"mime": "^4.1.0",
"p-limit": "^7.2.0",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.46.1",
"svelte-check": "^4.3.5",
"tailwindcss": "^3.4.19",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1",
"svelte": "^5.43.2",
"svelte-check": "^4.3.3",
"tailwindcss": "^3.4.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.51.0",
"typescript-eslint": "^8.46.2",
"unplugin-icons": "^22.5.0",
"vite": "^7.3.0"
"vite": "^7.1.12"
},
"dependencies": {
"@fastify/busboy": "^3.2.0",
"@trpc/server": "^11.8.1",
"@trpc/server": "^11.7.1",
"argon2": "^0.44.0",
"kysely": "^0.28.9",
"kysely": "^0.28.8",
"ms": "^2.1.3",
"node-schedule": "^2.1.1",
"pg": "^8.16.3",
"superjson": "^2.2.6",
"uuid": "^13.0.0",
"zod": "^4.3.5"
"zod": "^3.25.76"
},
"engines": {
"node": "^22.0.0",

1422
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +0,0 @@
<script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
class?: ClassValue;
count: number;
item: Snippet<[index: number]>;
itemHeight: (index: number) => number;
itemGap?: number;
placeholder?: Snippet;
}
let { class: className, count, item, itemHeight, itemGap, placeholder }: Props = $props();
let element: HTMLElement | undefined = $state();
let scrollMargin = $state(0);
let virtualizer = $derived(
createWindowVirtualizer({
count,
estimateSize: itemHeight,
gap: itemGap,
scrollMargin,
}),
);
const measureItem = (node: HTMLElement) => {
$effect(() => $virtualizer.measureElement(node));
};
$effect(() => {
if (!element) return;
const observer = new ResizeObserver(() => {
scrollMargin = Math.round(element!.getBoundingClientRect().top + window.scrollY);
});
observer.observe(element.parentElement!);
return () => observer.disconnect();
});
</script>
<div bind:this={element} class={["relative", className]}>
<div style:height="{$virtualizer.getTotalSize()}px">
{#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)}
<div
class="absolute left-0 top-0 w-full"
style:transform="translateY({virtualItem.start - scrollMargin}px)"
data-index={virtualItem.index}
use:measureItem
>
{@render item(virtualItem.index)}
</div>
{/each}
</div>
{#if placeholder && count === 0}
{@render placeholder()}
{/if}
</div>

View File

@@ -1,24 +0,0 @@
<script lang="ts">
import { getFileThumbnail } from "$lib/modules/file";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
interface Props {
info: SummarizedFileInfo;
onclick?: (file: SummarizedFileInfo) => void;
}
let { info, onclick }: Props = $props();
let thumbnail = $derived(getFileThumbnail(info));
</script>
<button
onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
>
{#if $thumbnail}
<img src={$thumbnail} alt={info.name} class="h-full w-full object-cover" />
{:else}
<div class="h-full w-full bg-gray-100"></div>
{/if}
</button>

View File

@@ -1,6 +1,5 @@
export { default as ActionEntryButton } from "./ActionEntryButton.svelte";
export { default as Button } from "./Button.svelte";
export { default as EntryButton } from "./EntryButton.svelte";
export { default as FileThumbnailButton } from "./FileThumbnailButton.svelte";
export { default as FloatingButton } from "./FloatingButton.svelte";
export { default as TextButton } from "./TextButton.svelte";

View File

@@ -3,4 +3,3 @@ export * from "./buttons";
export * from "./divs";
export * from "./inputs";
export { default as Modal } from "./Modal.svelte";
export { default as RowVirtualizer } from "./RowVirtualizer.svelte";

View File

@@ -1,44 +0,0 @@
<script module lang="ts">
import type { DataKey } from "$lib/modules/filesystem";
export interface SelectedCategory {
id: number;
dataKey?: DataKey;
name: string;
}
</script>
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { ActionEntryButton } from "$lib/components/atoms";
import { CategoryLabel } from "$lib/components/molecules";
import type { SubCategoryInfo } from "$lib/modules/filesystem";
import { sortEntries } from "$lib/utils";
interface Props {
categories: SubCategoryInfo[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
}
let { categories, categoryMenuIcon, onCategoryClick, onCategoryMenuClick }: Props = $props();
let categoriesWithName = $derived(sortEntries([...categories]));
</script>
{#if categoriesWithName.length > 0}
<div class="space-y-1">
{#each categoriesWithName as category (category.id)}
<ActionEntryButton
class="h-12"
onclick={() => onCategoryClick(category)}
actionButtonIcon={categoryMenuIcon}
onActionButtonClick={() => onCategoryMenuClick?.(category)}
>
<CategoryLabel name={category.name} />
</ActionEntryButton>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { untrack, type Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { get, type Writable } from "svelte/store";
import type { CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import Category from "./Category.svelte";
import type { SelectedCategory } from "./service";
interface Props {
categories: Writable<CategoryInfo | null>[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
sortBy?: SortBy;
}
let {
categories,
categoryMenuIcon,
onCategoryClick,
onCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
}: Props = $props();
let categoriesWithName: { name?: string; info: Writable<CategoryInfo | null> }[] = $state([]);
$effect(() => {
categoriesWithName = categories.map((category) => ({
name: get(category)?.name,
info: category,
}));
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 categoriesWithName.length > 0}
<div class="space-y-1">
{#each categoriesWithName as { info }}
<Category
{info}
menuIcon={categoryMenuIcon}
onclick={onCategoryClick}
onMenuClick={onCategoryMenuClick}
/>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,43 @@
<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 { CategoryInfo } from "$lib/modules/filesystem";
import type { SelectedCategory } from "./service";
interface Props {
info: Writable<CategoryInfo | null>;
menuIcon?: Component<SvelteHTMLElements["svg"]>;
onclick: (category: SelectedCategory) => void;
onMenuClick?: (category: SelectedCategory) => void;
}
let { info, menuIcon, onclick, onMenuClick }: Props = $props();
const openCategory = () => {
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 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!} />
</ActionEntryButton>
{/if}

View File

@@ -0,0 +1,2 @@
export { default } from "./Categories.svelte";
export * from "./service";

View File

@@ -0,0 +1,6 @@
export interface SelectedCategory {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string;
}

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/filesystem";
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]}>
@@ -43,12 +53,14 @@
{#if subCategoryCreatePosition === "top"}
{@render subCategoryCreate()}
{/if}
<Categories
categories={info.subCategories}
categoryMenuIcon={subCategoryMenuIcon}
onCategoryClick={onSubCategoryClick}
onCategoryMenuClick={onSubCategoryMenuClick}
/>
{#key info}
<Categories
categories={subCategories}
categoryMenuIcon={subCategoryMenuIcon}
onCategoryClick={onSubCategoryClick}
onCategoryMenuClick={onSubCategoryMenuClick}
/>
{/key}
{#if subCategoryCreatePosition === "bottom"}
{@render subCategoryCreate()}
{/if}

View File

@@ -1,7 +1,7 @@
export * from "./ActionModal.svelte";
export { default as ActionModal } from "./ActionModal.svelte";
export * from "./Categories.svelte";
export { default as Categories } from "./Categories.svelte";
export * from "./Categories";
export { default as Categories } from "./Categories";
export { default as IconEntryButton } from "./IconEntryButton.svelte";
export * from "./labels";
export { default as SubCategories } from "./SubCategories.svelte";

View File

@@ -3,7 +3,6 @@
import { IconLabel } from "$lib/components/molecules";
import IconFolder from "~icons/material-symbols/folder";
import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload";
import IconDraft from "~icons/material-symbols/draft";
interface Props {
@@ -12,7 +11,7 @@
subtext?: string;
textClass?: ClassValue;
thumbnail?: string;
type: "directory" | "parent-directory" | "file";
type: "directory" | "file";
}
let {
@@ -31,8 +30,6 @@
<img src={thumbnail} alt={name} loading="lazy" class="aspect-square rounded object-cover" />
{:else if type === "directory"}
<IconFolder />
{:else if type === "parent-directory"}
<IconDriveFolderUpload class="text-yellow-500" />
{:else}
<IconDraft class="text-blue-400" />
{/if}

View File

@@ -0,0 +1,107 @@
<script lang="ts">
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 FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
import type { SelectedFile } from "./service";
import IconMoreVert from "~icons/material-symbols/more-vert";
interface Props {
info: CategoryInfo;
onFileClick: (file: SelectedFile) => void;
onFileRemoveClick: (file: SelectedFile) => void;
onSubCategoryClick: (subCategory: SelectedCategory) => void;
onSubCategoryCreateClick: () => void;
onSubCategoryMenuClick: (subCategory: SelectedCategory) => void;
sortBy?: SortBy;
isFileRecursive: boolean;
}
let {
info,
onFileClick,
onFileRemoveClick,
onSubCategoryClick,
onSubCategoryCreateClick,
onSubCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
isFileRecursive = $bindable(),
}: Props = $props();
let files: { name?: string; info: Writable<FileInfo | null>; isRecursive: boolean }[] = $state(
[],
);
$effect(() => {
files =
info.files
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive)
.map(({ id, isRecursive }) => {
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
return {
name: get(info)?.name,
info,
isRecursive,
};
}) ?? [];
const sort = () => {
sortEntries(files, sortBy);
};
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">
<div class="space-y-4 bg-white p-4">
{#if info.id !== "root"}
<p class="text-lg font-bold text-gray-800">하위 카테고리</p>
{/if}
<SubCategories
{info}
{onSubCategoryClick}
{onSubCategoryCreateClick}
{onSubCategoryMenuClick}
subCategoryMenuIcon={IconMoreVert}
/>
</div>
{#if info.id !== "root"}
<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>
<CheckBox bind:checked={isFileRecursive}>
<p class="font-medium">하위 카테고리의 파일</p>
</CheckBox>
</div>
<div class="space-y-1">
{#key info}
{#each files as { info, isRecursive }}
<File
{info}
onclick={onFileClick}
onRemoveClick={!isRecursive ? onFileRemoveClick : undefined}
/>
{:else}
<p class="text-gray-500 text-center">이 카테고리에 추가된 파일이 없어요.</p>
{/each}
{/key}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,59 @@
<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/filesystem";
import { requestFileThumbnailDownload, type SelectedFile } from "./service";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: Writable<FileInfo | null>;
onclick: (selectedFile: SelectedFile) => void;
onRemoveClick?: (selectedFile: SelectedFile) => void;
}
let { info, onclick, onRemoveClick }: Props = $props();
let thumbnail: string | undefined = $state();
const openFile = () => {
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 as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script>
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openFile}
actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={removeFile}
>
<DirectoryEntryLabel type="file" {thumbnail} name={$info.name} />
</ActionEntryButton>
{/if}

View File

@@ -0,0 +1,2 @@
export { default } from "./Category.svelte";
export * from "./service";

View File

@@ -0,0 +1,8 @@
export { requestFileThumbnailDownload } from "$lib/services/file";
export interface SelectedFile {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string;
}

View File

@@ -1 +1,3 @@
export * from "./Category";
export { default as Category } from "./Category";
export * from "./modals";

11
src/lib/hooks/callApi.ts Normal file
View File

@@ -0,0 +1,11 @@
export const callGetApi = async (input: RequestInfo, fetchInternal = fetch) => {
return await fetchInternal(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,
});
};

2
src/lib/hooks/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./callApi";
export * from "./gotoStateful";

View File

@@ -1,5 +1,7 @@
import { Dexie, type EntityTable } from "dexie";
export type DirectoryId = "root" | number;
interface DirectoryInfo {
id: number;
parentId: DirectoryId;
@@ -13,15 +15,17 @@ interface FileInfo {
contentType: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds?: number[];
categoryIds: number[];
}
export type CategoryId = "root" | number;
interface CategoryInfo {
id: number;
parentId: CategoryId;
name: string;
files?: { id: number; isRecursive: boolean }[];
isFileRecursive?: boolean;
files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
}
const filesystem = new Dexie("filesystem") as Dexie & {
@@ -55,23 +59,13 @@ export const getDirectoryInfo = async (id: number) => {
};
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
await filesystem.directory.upsert(directoryInfo.id, { ...directoryInfo });
await filesystem.directory.put(directoryInfo);
};
export const deleteDirectoryInfo = async (id: number) => {
await filesystem.directory.delete(id);
};
export const deleteDanglingDirectoryInfos = async (
parentId: DirectoryId,
validIds: Set<number>,
) => {
await filesystem.directory
.where({ parentId })
.and((directory) => !validIds.has(directory.id))
.delete();
};
export const getAllFileInfos = async () => {
return await filesystem.file.toArray();
};
@@ -84,29 +78,14 @@ 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.upsert(fileInfo.id, { ...fileInfo });
await filesystem.file.put(fileInfo);
};
export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id);
};
export const bulkDeleteFileInfos = async (ids: number[]) => {
await filesystem.file.bulkDelete(ids);
};
export const deleteDanglingFileInfos = async (parentId: DirectoryId, validIds: Set<number>) => {
await filesystem.file
.where({ parentId })
.and((file) => !validIds.has(file.id))
.delete();
};
export const getCategoryInfos = async (parentId: CategoryId) => {
return await filesystem.category.where({ parentId }).toArray();
};
@@ -116,7 +95,7 @@ export const getCategoryInfo = async (id: number) => {
};
export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.upsert(categoryInfo.id, { ...categoryInfo });
await filesystem.category.put(categoryInfo);
};
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
@@ -127,13 +106,6 @@ export const deleteCategoryInfo = async (id: number) => {
await filesystem.category.delete(id);
};
export const deleteDanglingCategoryInfos = async (parentId: CategoryId, validIds: Set<number>) => {
await filesystem.category
.where({ parentId })
.and((category) => !validIds.has(category.id))
.delete();
};
export const cleanupDanglingInfos = async () => {
const validDirectoryIds: number[] = [];
const validFileIds: number[] = [];

View File

@@ -1,12 +1,15 @@
import { LRUCache } from "lru-cache";
import {
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
storeFileCacheIndex,
deleteFileCacheIndex,
type FileCacheIndex,
} from "$lib/indexedDB";
import { readFile, writeFile, deleteFile } from "$lib/modules/opfs";
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
const fileCacheIndex = new Map<number, FileCacheIndex>();
const loadedThumbnails = new LRUCache<number, string>({ max: 100 });
export const prepareFileCache = async () => {
for (const cache of await getFileCacheIndexFromIndexedDB()) {
@@ -48,3 +51,30 @@ export const deleteFileCache = async (fileId: number) => {
await deleteFile(`/cache/${fileId}`);
await deleteFileCacheIndex(fileId);
};
export const getFileThumbnailCache = async (fileId: number) => {
const thumbnail = loadedThumbnails.get(fileId);
if (thumbnail) return thumbnail;
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
if (!thumbnailBuffer) return null;
const thumbnailUrl = getThumbnailUrl(thumbnailBuffer);
loadedThumbnails.set(fileId, thumbnailUrl);
return thumbnailUrl;
};
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer));
};
export const deleteFileThumbnailCache = async (fileId: number) => {
loadedThumbnails.delete(fileId);
await deleteFile(`/thumbnail/file/${fileId}`);
};
export const deleteAllFileThumbnailCaches = async () => {
loadedThumbnails.clear();
await deleteDirectory("/thumbnail/file");
};

View File

@@ -1,95 +0,0 @@
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;
}
};

View File

@@ -0,0 +1,84 @@
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<FileDownloadStatus>, 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<FileDownloadStatus>,
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<FileDownloadStatus>({
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;
}
};

View File

@@ -1,4 +1,3 @@
export * from "./cache";
export * from "./download.svelte";
export * from "./thumbnail";
export * from "./upload.svelte";
export * from "./download";
export * from "./upload";

View File

@@ -1,90 +0,0 @@
import { LRUCache } from "lru-cache";
import { writable, type Writable } from "svelte/store";
import { browser } from "$app/environment";
import { decryptData } from "$lib/modules/crypto";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
import { isTRPCClientError, trpc } from "$trpc/client";
const loadedThumbnails = new LRUCache<number, Writable<string>>({ max: 100 });
const loadingThumbnails = new Map<number, Writable<string | undefined>>();
const fetchFromOpfs = async (fileId: number) => {
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
if (thumbnailBuffer) {
return getThumbnailUrl(thumbnailBuffer);
}
};
const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
try {
const [thumbnailEncrypted, { contentIv: thumbnailEncryptedIv }] = await Promise.all([
fetch(`/api/file/${fileId}/thumbnail/download`),
trpc().file.thumbnail.query({ id: fileId }),
]);
const thumbnailBuffer = await decryptData(
await thumbnailEncrypted.arrayBuffer(),
thumbnailEncryptedIv,
dataKey,
);
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
return getThumbnailUrl(thumbnailBuffer);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
return null;
}
throw e;
}
};
export const getFileThumbnail = (file: SummarizedFileInfo) => {
if (
!browser ||
!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))
) {
return undefined;
}
const thumbnail = loadedThumbnails.get(file.id);
if (thumbnail) return thumbnail;
let loadingThumbnail = loadingThumbnails.get(file.id);
if (loadingThumbnail) return loadingThumbnail;
loadingThumbnail = writable(undefined);
loadingThumbnails.set(file.id, loadingThumbnail);
fetchFromOpfs(file.id)
.then((thumbnail) => thumbnail ?? (file.dataKey && fetchFromServer(file.id, file.dataKey.key)))
.then((thumbnail) => {
if (thumbnail) {
loadingThumbnail.set(thumbnail);
loadedThumbnails.set(file.id, loadingThumbnail as Writable<string>);
}
loadingThumbnails.delete(file.id);
});
return loadingThumbnail;
};
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
const oldThumbnail = loadedThumbnails.get(fileId);
if (oldThumbnail) {
oldThumbnail.set(getThumbnailUrl(thumbnailBuffer));
} else {
loadedThumbnails.set(fileId, writable(getThumbnailUrl(thumbnailBuffer)));
}
};
export const deleteFileThumbnailCache = async (fileId: number) => {
loadedThumbnails.delete(fileId);
await deleteFile(`/thumbnail/file/${fileId}`);
};
export const deleteAllFileThumbnailCaches = async () => {
loadedThumbnails.clear();
await deleteDirectory(`/thumbnail/file`);
};

View File

@@ -1,6 +1,7 @@
import axios from "axios";
import ExifReader from "exifreader";
import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import {
encodeToBase64,
generateDataKey,
@@ -10,65 +11,32 @@ import {
digestMessage,
signMessageHmac,
} from "$lib/modules/crypto";
import { Scheduler } from "$lib/modules/scheduler";
import { generateThumbnail } from "$lib/modules/thumbnail";
import type {
DuplicateFileScanRequest,
DuplicateFileScanResponse,
FileThumbnailUploadRequest,
FileUploadRequest,
FileUploadResponse,
} from "$lib/server/schemas";
import type { MasterKey, HmacSecret } from "$lib/stores";
import { trpc } from "$trpc/client";
export interface FileUploadState {
name: string;
parentId: DirectoryId;
status:
| "queued"
| "encryption-pending"
| "encrypting"
| "upload-pending"
| "uploading"
| "uploaded"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
}
export type LiveFileUploadState = FileUploadState & {
status: "queued" | "encryption-pending" | "encrypting" | "upload-pending" | "uploading";
};
const scheduler = new Scheduler<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
>();
let uploadingFiles: FileUploadState[] = $state([]);
const isFileUploading = (status: FileUploadState["status"]) =>
["queued", "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));
};
import {
fileUploadStatusStore,
type MasterKey,
type HmacSecret,
type FileUploadStatus,
} from "$lib/stores";
const requestDuplicateFileScan = limitFunction(
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
const fileBuffer = await file.arrayBuffer();
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
const files = await trpc().file.listByHash.query({
const res = await axios.post("/api/file/scanDuplicates", {
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
});
} satisfies DuplicateFileScanRequest);
const { files }: DuplicateFileScanResponse = res.data;
if (files.length === 0 || (await onDuplicate())) {
return { fileBuffer, fileSigned };
} else {
@@ -111,8 +79,16 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
};
const encryptFile = limitFunction(
async (state: FileUploadState, file: File, fileBuffer: ArrayBuffer, masterKey: MasterKey) => {
state.status = "encrypting";
async (
status: Writable<FileUploadStatus>,
file: File,
fileBuffer: ArrayBuffer,
masterKey: MasterKey,
) => {
status.update((value) => {
value.status = "encrypting";
return value;
});
const fileType = getFileType(file);
@@ -136,7 +112,10 @@ const encryptFile = limitFunction(
const thumbnailBuffer = await thumbnail?.arrayBuffer();
const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey));
state.status = "upload-pending";
status.update((value) => {
value.status = "upload-pending";
return value;
});
return {
dataKeyWrapped,
@@ -154,14 +133,20 @@ const encryptFile = limitFunction(
);
const requestFileUpload = limitFunction(
async (state: FileUploadState, form: FormData, thumbnailForm: FormData | null) => {
state.status = "uploading";
async (status: Writable<FileUploadStatus>, form: FormData, thumbnailForm: FormData | null) => {
status.update((value) => {
value.status = "uploading";
return value;
});
const res = await axios.post("/api/file/upload", form, {
onUploadProgress: ({ progress, rate, estimated }) => {
state.progress = progress;
state.rate = rate;
state.estimated = estimated;
status.update((value) => {
value.progress = progress;
value.rate = rate;
value.estimated = estimated;
return value;
});
},
});
const { file }: FileUploadResponse = res.data;
@@ -175,7 +160,10 @@ const requestFileUpload = limitFunction(
}
}
state.status = "uploaded";
status.update((value) => {
value.status = "uploaded";
return value;
});
return { fileId: file };
},
@@ -188,82 +176,92 @@ export const uploadFile = async (
hmacSecret: HmacSecret,
masterKey: MasterKey,
onDuplicate: () => Promise<boolean>,
) => {
uploadingFiles.push({
): Promise<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
> => {
const status = writable<FileUploadStatus>({
name: file.name,
parentId,
status: "queued",
status: "encryption-pending",
});
fileUploadStatusStore.update((value) => {
value.push(status);
return value;
});
const state = uploadingFiles.at(-1)!;
return await scheduler.schedule(file.size, async () => {
state.status = "encryption-pending";
try {
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
file,
hmacSecret,
onDuplicate,
);
if (!fileBuffer || !fileSigned) {
status.update((value) => {
value.status = "canceled";
return value;
});
fileUploadStatusStore.update((value) => {
value = value.filter((v) => v !== status);
return value;
});
return undefined;
}
try {
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
file,
hmacSecret,
onDuplicate,
);
if (!fileBuffer || !fileSigned) {
state.status = "canceled";
uploadingFiles = uploadingFiles.filter((file) => file !== state);
return undefined;
}
const {
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail,
} = await encryptFile(status, file, fileBuffer, masterKey);
const {
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail,
} = await encryptFile(state, file, fileBuffer, masterKey);
const form = new FormData();
form.set(
"metadata",
JSON.stringify({
parent: parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion.toISOString(),
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
contentType: fileType,
contentIv: fileEncrypted.iv,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
} satisfies FileUploadRequest),
);
form.set("content", new Blob([fileEncrypted.ciphertext]));
form.set("checksum", fileEncryptedHash);
const form = new FormData();
form.set(
let thumbnailForm = null;
if (thumbnail) {
thumbnailForm = new FormData();
thumbnailForm.set(
"metadata",
JSON.stringify({
parent: parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion.toISOString(),
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
contentType: fileType,
contentIv: fileEncrypted.iv,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
} satisfies FileUploadRequest),
contentIv: thumbnail.iv,
} satisfies FileThumbnailUploadRequest),
);
form.set("content", new Blob([fileEncrypted.ciphertext]));
form.set("checksum", fileEncryptedHash);
let thumbnailForm = null;
if (thumbnail) {
thumbnailForm = new FormData();
thumbnailForm.set(
"metadata",
JSON.stringify({
dekVersion: dataKeyVersion.toISOString(),
contentIv: thumbnail.iv,
} satisfies FileThumbnailUploadRequest),
);
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
}
const { fileId } = await requestFileUpload(state, form, thumbnailForm);
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
} catch (e) {
state.status = "error";
throw e;
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
}
});
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
} catch (e) {
status.update((value) => {
value.status = "error";
return value;
});
throw e;
}
};

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,121 +0,0 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { CategoryInfo, MaybeCategoryInfo } from "./types";
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
async fetchFromIndexedDB(id) {
const [category, subCategories] = await Promise.all([
id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined,
IndexedDB.getCategoryInfos(id),
]);
const files = category?.files
? await Promise.all(
category.files.map(async (file) => {
const fileInfo = await IndexedDB.getFileInfo(file.id);
return fileInfo
? {
id: file.id,
parentId: fileInfo.parentId,
contentType: fileInfo.contentType,
name: fileInfo.name,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
isRecursive: file.isRecursive,
}
: undefined;
}),
)
: undefined;
if (id === "root") {
return {
id,
exists: true,
subCategories,
};
} else if (category) {
return {
id,
exists: true,
parentId: category.parentId,
name: category.name,
subCategories,
files: files?.filter((file) => !!file) ?? [],
isFileRecursive: category.isFileRecursive ?? false,
};
}
},
async fetchFromServer(id, cachedInfo, masterKey) {
try {
const category = await trpc().category.get.query({ id, recurse: true });
const [subCategories, files, metadata] = await Promise.all([
Promise.all(
category.subCategories.map(async (category) => ({
id: category.id,
parentId: id,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
category.files &&
Promise.all(
category.files.map(async (file) => ({
id: file.id,
parentId: file.parent,
contentType: file.contentType,
isRecursive: file.isRecursive,
...(await decryptFileMetadata(file, masterKey)),
})),
),
category.metadata && decryptCategoryMetadata(category.metadata, masterKey),
]);
return storeToIndexedDB(
id !== "root"
? {
id,
parentId: category.metadata!.parent,
subCategories,
files: files!,
isFileRecursive: cachedInfo?.isFileRecursive ?? false,
...metadata!,
}
: { id, subCategories },
);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteCategoryInfo(id as number);
return { id, exists: false };
}
throw e;
}
},
});
const storeToIndexedDB = (info: CategoryInfo) => {
if (info.id !== "root") {
void IndexedDB.storeCategoryInfo(info);
// TODO: Bulk Upsert
new Map(info.files.map((file) => [file.id, file])).forEach((file) => {
void IndexedDB.storeFileInfo(file);
});
}
// TODO: Bulk Upsert
info.subCategories.forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
void IndexedDB.deleteDanglingCategoryInfos(
info.id,
new Set(info.subCategories.map(({ id }) => id)),
);
return { ...info, exists: true as const };
};
export const getCategoryInfo = (id: CategoryId, masterKey: CryptoKey) => {
return cache.get(id, masterKey);
};

View File

@@ -1,102 +0,0 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte";
import type { DirectoryInfo, MaybeDirectoryInfo } from "./types";
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
async fetchFromIndexedDB(id) {
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,
subDirectories,
files,
};
} else if (directory) {
return {
id,
exists: true,
parentId: directory.parentId,
name: directory.name,
subDirectories,
files,
};
}
},
async fetchFromServer(id, _cachedInfo, masterKey) {
try {
const directory = await trpc().directory.get.query({ id });
const [subDirectories, files, metadata] = await Promise.all([
Promise.all(
directory.subDirectories.map(async (directory) => ({
id: directory.id,
parentId: id,
...(await decryptDirectoryMetadata(directory, masterKey)),
})),
),
Promise.all(
directory.files.map(async (file) => ({
id: file.id,
parentId: id,
contentType: file.contentType,
...(await decryptFileMetadata(file, masterKey)),
})),
),
directory.metadata && decryptDirectoryMetadata(directory.metadata, masterKey),
]);
return storeToIndexedDB(
id !== "root"
? {
id,
parentId: directory.metadata!.parent,
subDirectories,
files,
...metadata!,
}
: { id, subDirectories, files },
);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteDirectoryInfo(id as number);
return { id, exists: false as const };
}
throw e;
}
},
});
const storeToIndexedDB = (info: DirectoryInfo) => {
if (info.id !== "root") {
void IndexedDB.storeDirectoryInfo(info);
}
// TODO: Bulk Upsert
info.subDirectories.forEach((subDirectory) => {
void IndexedDB.storeDirectoryInfo(subDirectory);
});
// TODO: Bulk Upsert
info.files.forEach((file) => {
void IndexedDB.storeFileInfo(file);
});
void IndexedDB.deleteDanglingDirectoryInfos(
info.id,
new Set(info.subDirectories.map(({ id }) => id)),
);
void IndexedDB.deleteDanglingFileInfos(info.id, new Set(info.files.map(({ id }) => id)));
return { ...info, exists: true as const };
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
return cache.get(id, masterKey);
};

View File

@@ -1,177 +0,0 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { FileInfo, MaybeFileInfo } from "./types";
const cache = new FilesystemCache<number, MaybeFileInfo>({
async fetchFromIndexedDB(id) {
const file = await IndexedDB.getFileInfo(id);
const categories = file?.categoryIds
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category
? { id: category.id, parentId: category.parentId, name: category.name }
: undefined;
}),
)
: undefined;
if (file) {
return {
id,
exists: true,
parentId: file.parentId,
contentType: file.contentType,
name: file.name,
createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt,
categories: categories?.filter((category) => !!category) ?? [],
};
}
},
async fetchFromServer(id, _cachedInfo, masterKey) {
try {
const file = await trpc().file.get.query({ id });
const [categories, metadata] = await Promise.all([
Promise.all(
file.categories.map(async (category) => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
decryptFileMetadata(file, masterKey),
]);
return storeToIndexedDB({
id,
parentId: file.parent,
dataKey: metadata.dataKey,
contentType: file.contentType,
contentIv: file.contentIv,
name: metadata.name,
createdAt: metadata.createdAt,
lastModifiedAt: metadata.lastModifiedAt,
categories,
});
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteFileInfo(id);
return { id, exists: false as const };
}
throw e;
}
},
async bulkFetchFromIndexedDB(ids) {
const files = await IndexedDB.bulkGetFileInfos([...ids]);
const categories = await Promise.all(
files.map(async (file) =>
file?.categoryIds
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category
? { id: category.id, parentId: category.parentId, name: category.name }
: undefined;
}),
)
: undefined,
),
);
return new Map(
files
.filter((file) => !!file)
.map((file, index) => [
file.id,
{
...file,
exists: true,
categories: categories[index]?.filter((category) => !!category) ?? [],
},
]),
);
},
async bulkFetchFromServer(ids, masterKey) {
const idsArray = [...ids.keys()];
const filesRaw = await trpc().file.bulkGet.query({ ids: idsArray });
const files = await Promise.all(
filesRaw.map(async ({ id, categories: categoriesRaw, ...metadataRaw }) => {
const [categories, metadata] = await Promise.all([
Promise.all(
categoriesRaw.map(async (category) => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
decryptFileMetadata(metadataRaw, masterKey),
]);
return {
id,
exists: true as const,
parentId: metadataRaw.parent,
contentType: metadataRaw.contentType,
contentIv: metadataRaw.contentIv,
categories,
...metadata,
};
}),
);
const existingIds = new Set(filesRaw.map(({ id }) => id));
const deletedIds = idsArray.filter((id) => !existingIds.has(id));
void IndexedDB.bulkDeleteFileInfos(deletedIds);
return new Map<number, MaybeFileInfo>([
...bulkStoreToIndexedDB(files),
...deletedIds.map((id) => [id, { id, exists: false }] as const),
]);
},
});
const storeToIndexedDB = (info: FileInfo) => {
void IndexedDB.storeFileInfo({
...info,
categoryIds: info.categories.map(({ id }) => id),
});
info.categories.forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
return { ...info, exists: true as const };
};
const bulkStoreToIndexedDB = (infos: FileInfo[]) => {
// TODO: Bulk Upsert
infos.forEach((info) => {
void IndexedDB.storeFileInfo({
...info,
categoryIds: info.categories.map(({ id }) => id),
});
});
// TODO: Bulk Upsert
new Map(
infos.flatMap(({ categories }) => categories).map((category) => [category.id, category]),
).forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
return infos.map((info) => [info.id, { ...info, exists: true }] as const);
};
export const getFileInfo = (id: number, masterKey: CryptoKey) => {
return cache.get(id, masterKey);
};
export const bulkGetFileInfo = (ids: number[], masterKey: CryptoKey) => {
return cache.bulkGet(new Set(ids), masterKey);
};

View File

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

View File

@@ -1,172 +0,0 @@
import { untrack } from "svelte";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
interface FilesystemCacheOptions<K, V> {
fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
fetchFromServer: (key: K, cachedValue: V | undefined, masterKey: CryptoKey) => Promise<V>;
bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
bulkFetchFromServer?: (
keys: Map<K, { cachedValue: V | undefined }>,
masterKey: CryptoKey,
) => Promise<Map<K, V>>;
}
export class FilesystemCache<K, V extends object> {
private map = new Map<K, { value?: V; promise?: Promise<V> }>();
constructor(private readonly options: FilesystemCacheOptions<K, V>) {}
get(key: K, masterKey: CryptoKey) {
return untrack(() => {
let state = this.map.get(key);
if (state?.promise) return state.value ?? state.promise;
const { promise: newPromise, resolve } = Promise.withResolvers<V>();
if (!state) {
const newState = $state({});
state = newState;
this.map.set(key, newState);
}
(state.value
? Promise.resolve(state.value)
: this.options.fetchFromIndexedDB(key).then((loadedInfo) => {
if (loadedInfo) {
state.value = loadedInfo;
resolve(state.value);
}
return loadedInfo;
})
)
.then((cachedInfo) => this.options.fetchFromServer(key, cachedInfo, masterKey))
.then((loadedInfo) => {
if (state.value) {
Object.assign(state.value, loadedInfo);
} else {
state.value = loadedInfo;
}
resolve(state.value);
})
.finally(() => {
state.promise = undefined;
});
state.promise = newPromise;
return state.value ?? newPromise;
});
}
bulkGet(keys: Set<K>, masterKey: CryptoKey) {
return untrack(() => {
const newPromises = new Map(
keys
.keys()
.filter((key) => this.map.get(key)?.promise === undefined)
.map((key) => [key, Promise.withResolvers<V>()]),
);
newPromises.forEach(({ promise }, key) => {
const state = this.map.get(key);
if (state) {
state.promise = promise;
} else {
const newState = $state({ promise });
this.map.set(key, newState);
}
});
const resolve = (loadedInfos: Map<K, V>) => {
loadedInfos.forEach((loadedInfo, key) => {
const state = this.map.get(key)!;
if (state.value) {
Object.assign(state.value, loadedInfo);
} else {
state.value = loadedInfo;
}
newPromises.get(key)!.resolve(state.value);
});
return loadedInfos;
};
this.options.bulkFetchFromIndexedDB!(
new Set(newPromises.keys().filter((key) => this.map.get(key)!.value === undefined)),
)
.then(resolve)
.then(() =>
this.options.bulkFetchFromServer!(
new Map(
newPromises.keys().map((key) => [key, { cachedValue: this.map.get(key)!.value }]),
),
masterKey,
),
)
.then(resolve)
.finally(() => {
newPromises.forEach((_, key) => {
this.map.get(key)!.promise = undefined;
});
});
const bottleneckPromises = Array.from(
keys
.keys()
.filter((key) => this.map.get(key)!.value === undefined)
.map((key) => this.map.get(key)!.promise!),
);
const makeResult = () =>
new Map(keys.keys().map((key) => [key, this.map.get(key)!.value!] as const));
return bottleneckPromises.length > 0
? Promise.all(bottleneckPromises).then(makeResult)
: makeResult();
});
}
}
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;

View File

@@ -1,77 +0,0 @@
export type DataKey = { key: CryptoKey; version: Date };
type AllUndefined<T> = { [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 MaybeDirectoryInfo =
| (DirectoryInfo & { exists: true })
| ({ id: DirectoryId; exists: false } & AllUndefined<Omit<DirectoryInfo, "id">>);
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "subDirectories" | "files">;
export interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categories: FileCategoryInfo[];
}
export type MaybeFileInfo =
| (FileInfo & { exists: true })
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
export type SummarizedFileInfo = Omit<FileInfo, "contentIv" | "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
interface LocalCategoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
name: string;
subCategories: SubCategoryInfo[];
files: CategoryFileInfo[];
isFileRecursive: boolean;
}
interface RootCategoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subCategories: SubCategoryInfo[];
files?: undefined;
isFileRecursive?: undefined;
}
export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo;
export type MaybeCategoryInfo =
| (CategoryInfo & { exists: true })
| ({ id: CategoryId; exists: false } & AllUndefined<Omit<CategoryInfo, "id">>);
export type SubCategoryInfo = Omit<
LocalCategoryInfo,
"subCategories" | "files" | "isFileRecursive"
>;
export type FileCategoryInfo = Omit<SubCategoryInfo, "dataKey">;

View File

@@ -5,14 +5,14 @@ import type { ClientKeys } from "$lib/stores";
const serializedClientKeysSchema = z.intersection(
z.object({
generator: z.literal("ArkVault"),
exportedAt: z.iso.datetime(),
exportedAt: z.string().datetime(),
}),
z.object({
version: z.literal(1),
encryptKey: z.base64().nonempty(),
decryptKey: z.base64().nonempty(),
signKey: z.base64().nonempty(),
verifyKey: z.base64().nonempty(),
encryptKey: z.string().base64().nonempty(),
decryptKey: z.string().base64().nonempty(),
signKey: z.string().base64().nonempty(),
verifyKey: z.string().base64().nonempty(),
}),
);

View File

@@ -1,48 +0,0 @@
export class Scheduler<T = void> {
private isEstimating = false;
private memoryUsage = 0;
private queue: (() => void)[] = [];
constructor(public readonly memoryLimit = 100 * 1024 * 1024 /* 100 MiB */) {}
private next() {
if (!this.isEstimating && this.memoryUsage < this.memoryLimit) {
const resolve = this.queue.shift();
if (resolve) {
this.isEstimating = true;
resolve();
}
}
}
async schedule(
estimateMemoryUsage: number | (() => number | Promise<number>),
task: () => Promise<T>,
) {
if (this.isEstimating || this.memoryUsage >= this.memoryLimit) {
await new Promise<void>((resolve) => {
this.queue.push(resolve);
});
} else {
this.isEstimating = true;
}
let taskMemoryUsage = 0;
try {
taskMemoryUsage =
typeof estimateMemoryUsage === "number" ? estimateMemoryUsage : await estimateMemoryUsage();
this.memoryUsage += taskMemoryUsage;
} finally {
this.isEstimating = false;
this.next();
}
try {
return await task();
} finally {
this.memoryUsage -= taskMemoryUsage;
this.next();
}
}
}

View File

@@ -7,13 +7,6 @@ export const formatDate = (date: Date) => {
return `${year}. ${month}. ${day}.`;
};
export const formatDateSortable = (date: Date) => {
const year = date.getFullYear();
const month = pad2(date.getMonth() + 1);
const day = pad2(date.getDate());
return `${year}${month}${day}`;
};
export const formatDateTime = (date: Date) => {
const dateFormatted = formatDate(date);
const hours = date.getHours();
@@ -39,3 +32,32 @@ export const truncateString = (str: string, maxLength = 20) => {
if (str.length <= maxLength) return str;
return `${str.slice(0, maxLength)}...`;
};
export enum SortBy {
NAME_ASC,
NAME_DESC,
}
type SortFunc = (a?: string, b?: string) => number;
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
const sortByNameAsc: SortFunc = (a, b) => {
if (a && b) return collator.compare(a, b);
if (a) return -1;
if (b) return 1;
return 0;
};
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
export const sortEntries = <T extends { name?: string }>(entries: T[], sortBy: SortBy) => {
let sortFunc: SortFunc;
if (sortBy === SortBy.NAME_ASC) {
sortFunc = sortByNameAsc;
} else {
sortFunc = sortByNameDesc;
}
entries.sort((a, b) => sortFunc(a.name, b.name));
};

View File

@@ -2,6 +2,8 @@ import { IntegrityError } from "./error";
import db from "./kysely";
import type { Ciphertext } from "./schema";
export type CategoryId = "root" | number;
interface Category {
id: number;
parentId: CategoryId;

View File

@@ -1,10 +1,11 @@
import { sql } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import { sql, type NotNull } from "kysely";
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;
@@ -37,15 +38,6 @@ interface File {
export type NewFile = Omit<File, "id">;
interface FileCategory {
id: number;
parentId: CategoryId;
mekVersion: number;
encDek: string;
dekVersion: Date;
encName: Ciphertext;
}
export const registerDirectory = async (params: NewDirectory) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
@@ -314,51 +306,39 @@ export const getAllFilesByCategory = async (
recurse: boolean,
) => {
const files = await db
.withRecursive("category_tree", (db) =>
.withRecursive("cte", (db) =>
db
.selectFrom("category")
.select(["id", sql<number>`0`.as("depth")])
.leftJoin("file_category", "category.id", "file_category.category_id")
.select(["id", "parent_id", "user_id", "file_category.file_id"])
.select(sql<number>`0`.as("depth"))
.where("id", "=", categoryId)
.where("user_id", "=", userId)
.$if(recurse, (qb) =>
qb.unionAll((db) =>
db
.selectFrom("category")
.innerJoin("category_tree", "category.parent_id", "category_tree.id")
.select(["category.id", sql<number>`depth + 1`.as("depth")]),
.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<number>`cte.depth + 1`.as("depth")),
),
),
)
.selectFrom("category_tree")
.innerJoin("file_category", "category_tree.id", "file_category.category_id")
.innerJoin("file", "file_category.file_id", "file.id")
.selectFrom("cte")
.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: 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 },
);
return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 }));
};
export const getAllFileIds = async (userId: number) => {
@@ -410,52 +390,6 @@ 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,
parentId: category.parent_id ?? "root",
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,
@@ -542,21 +476,10 @@ export const addFileToCategory = async (fileId: number, categoryId: number) => {
export const getAllFileCategories = async (fileId: number) => {
const categories = await db
.selectFrom("file_category")
.innerJoin("category", "file_category.category_id", "category.id")
.selectAll("category")
.select("category_id")
.where("file_id", "=", fileId)
.execute();
return categories.map(
(category) =>
({
id: category.id,
parentId: category.parent_id ?? "root",
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version,
encName: category.encrypted_name,
}) satisfies FileCategory,
);
return categories.map(({ category_id }) => ({ id: category_id }));
};
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {

View File

@@ -27,6 +27,10 @@ export const getUserByEmail = async (email: string) => {
return user ? (user satisfies User) : null;
};
export const setUserNickname = async (userId: number, nickname: string) => {
await db.updateTable("user").set({ nickname }).where("id", "=", userId).execute();
};
export const setUserPassword = async (userId: number, password: string) => {
await db.updateTable("user").set({ password }).where("id", "=", userId).execute();
};

View File

@@ -1,7 +1,13 @@
import { error, redirect, type Handle } from "@sveltejs/kit";
import { cookieOptions, authenticate, AuthenticationError } from "$lib/server/modules/auth";
import env from "$lib/server/loadenv";
import { authenticate, AuthenticationError } from "$lib/server/modules/auth";
export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
const { pathname, search } = event.url;
if (pathname === "/api/auth/login" || pathname.startsWith("/api/trpc")) {
return await resolve(event);
}
try {
const sessionIdSigned = event.cookies.get("sessionId");
if (!sessionIdSigned) {
@@ -10,11 +16,15 @@ export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
const { ip, userAgent } = event.locals;
event.locals.session = await authenticate(sessionIdSigned, ip, userAgent);
event.cookies.set("sessionId", sessionIdSigned, cookieOptions);
event.cookies.set("sessionId", sessionIdSigned, {
path: "/",
maxAge: env.session.exp / 1000,
secure: true,
sameSite: "strict",
});
} catch (e) {
if (e instanceof AuthenticationError) {
const { pathname, search } = event.url;
if (pathname === "/auth/login" || pathname.startsWith("/api/trpc")) {
if (pathname === "/auth/login") {
return await resolve(event);
} else if (pathname.startsWith("/api")) {
error(e.status, e.message);

View File

@@ -1,9 +1,11 @@
import { error } from "@sveltejs/kit";
import { ClientRepo, SessionRepo, IntegrityError } from "$lib/server/db";
import { getUserClient } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { createSession, refreshSession } from "$lib/server/db/session";
import env from "$lib/server/loadenv";
import { verifySessionId } from "$lib/server/modules/crypto";
import { issueSessionId, verifySessionId } from "$lib/server/modules/crypto";
export interface Session {
interface Session {
sessionId: string;
userId: number;
clientId?: number;
@@ -40,12 +42,11 @@ export class AuthorizationError extends Error {
}
}
export const cookieOptions = {
path: "/",
maxAge: env.session.exp / 1000,
secure: true,
sameSite: "strict",
} as const;
export const startSession = async (userId: number, ip: string, userAgent: string) => {
const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret);
await createSession(userId, sessionId, ip, userAgent);
return sessionIdSigned;
};
export const authenticate = async (sessionIdSigned: string, ip: string, userAgent: string) => {
const sessionId = verifySessionId(sessionIdSigned, env.session.secret);
@@ -54,7 +55,7 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
}
try {
const { userId, clientId } = await SessionRepo.refreshSession(sessionId, ip, userAgent);
const { userId, clientId } = await refreshSession(sessionId, ip, userAgent);
return {
id: sessionId,
userId,
@@ -95,7 +96,7 @@ export const authorizeInternal = async (
if (!clientId) {
throw new AuthorizationError(403, "Forbidden");
}
const userClient = await ClientRepo.getUserClient(userId, clientId);
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
throw new AuthorizationError(500, "Invalid session id");
} else if (userClient.state !== "pending") {
@@ -107,7 +108,7 @@ export const authorizeInternal = async (
if (!clientId) {
throw new AuthorizationError(403, "Forbidden");
}
const userClient = await ClientRepo.getUserClient(userId, clientId);
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
throw new AuthorizationError(500, "Invalid session id");
} else if (userClient.state !== "active") {

View File

@@ -1,7 +0,0 @@
import { unlink } from "fs/promises";
export const safeUnlink = async (path: string | null | undefined) => {
if (path) {
await unlink(path).catch(console.error);
}
};

View File

@@ -0,0 +1,32 @@
import { z } from "zod";
export const passwordChangeRequest = z.object({
oldPassword: z.string().trim().nonempty(),
newPassword: z.string().trim().nonempty(),
});
export type PasswordChangeRequest = z.input<typeof passwordChangeRequest>;
export const loginRequest = z.object({
email: z.string().email(),
password: z.string().trim().nonempty(),
});
export type LoginRequest = z.input<typeof loginRequest>;
export const sessionUpgradeRequest = z.object({
encPubKey: z.string().base64().nonempty(),
sigPubKey: z.string().base64().nonempty(),
});
export type SessionUpgradeRequest = z.input<typeof sessionUpgradeRequest>;
export const sessionUpgradeResponse = z.object({
id: z.number().int().positive(),
challenge: z.string().base64().nonempty(),
});
export type SessionUpgradeResponse = z.output<typeof sessionUpgradeResponse>;
export const sessionUpgradeVerifyRequest = z.object({
id: z.number().int().positive(),
answerSig: z.string().base64().nonempty(),
force: z.boolean().default(false),
});
export type SessionUpgradeVerifyRequest = z.input<typeof sessionUpgradeVerifyRequest>;

View File

@@ -1,3 +1,55 @@
import { z } from "zod";
export const categoryIdSchema = z.union([z.literal("root"), z.int().positive()]);
export const categoryIdSchema = z.union([z.literal("root"), z.number().int().positive()]);
export const categoryInfoResponse = z.object({
metadata: z
.object({
parent: categoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
})
.optional(),
subCategories: z.number().int().positive().array(),
});
export type CategoryInfoResponse = z.output<typeof categoryInfoResponse>;
export const categoryFileAddRequest = z.object({
file: z.number().int().positive(),
});
export type CategoryFileAddRequest = z.input<typeof categoryFileAddRequest>;
export const categoryFileListResponse = z.object({
files: z.array(
z.object({
file: z.number().int().positive(),
isRecursive: z.boolean(),
}),
),
});
export type CategoryFileListResponse = z.output<typeof categoryFileListResponse>;
export const categoryFileRemoveRequest = z.object({
file: z.number().int().positive(),
});
export type CategoryFileRemoveRequest = z.input<typeof categoryFileRemoveRequest>;
export const categoryRenameRequest = z.object({
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
});
export type CategoryRenameRequest = z.input<typeof categoryRenameRequest>;
export const categoryCreateRequest = z.object({
parent: categoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
});
export type CategoryCreateRequest = z.input<typeof categoryCreateRequest>;

View File

@@ -1,3 +1,41 @@
import { z } from "zod";
export const directoryIdSchema = z.union([z.literal("root"), z.int().positive()]);
export const directoryIdSchema = z.union([z.literal("root"), z.number().int().positive()]);
export const directoryInfoResponse = z.object({
metadata: z
.object({
parent: directoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
})
.optional(),
subDirectories: z.number().int().positive().array(),
files: z.number().int().positive().array(),
});
export type DirectoryInfoResponse = z.output<typeof directoryInfoResponse>;
export const directoryDeleteResponse = z.object({
deletedFiles: z.number().int().positive().array(),
});
export type DirectoryDeleteResponse = z.output<typeof directoryDeleteResponse>;
export const directoryRenameRequest = z.object({
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
});
export type DirectoryRenameRequest = z.input<typeof directoryRenameRequest>;
export const directoryCreateRequest = z.object({
parent: directoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
});
export type DirectoryCreateRequest = z.input<typeof directoryCreateRequest>;

View File

@@ -2,35 +2,90 @@ import mime from "mime";
import { z } from "zod";
import { directoryIdSchema } from "./directory";
export const fileThumbnailUploadRequest = z.object({
dekVersion: z.iso.datetime(),
contentIv: z.base64().nonempty(),
});
export type FileThumbnailUploadRequest = z.input<typeof fileThumbnailUploadRequest>;
export const fileUploadRequest = z.object({
export const fileInfoResponse = z.object({
parent: directoryIdSchema,
mekVersion: z.int().positive(),
dek: z.base64().nonempty(),
dekVersion: z.iso.datetime(),
hskVersion: z.int().positive(),
contentHmac: z.base64().nonempty(),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
contentType: z
.string()
.trim()
.nonempty()
.refine((value) => mime.getExtension(value) !== null), // MIME type
contentIv: z.base64().nonempty(),
name: z.base64().nonempty(),
nameIv: z.base64().nonempty(),
createdAt: z.base64().nonempty().optional(),
createdAtIv: z.base64().nonempty().optional(),
lastModifiedAt: z.base64().nonempty(),
lastModifiedAtIv: z.base64().nonempty(),
contentIv: z.string().base64().nonempty(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
createdAt: z.string().base64().nonempty().optional(),
createdAtIv: z.string().base64().nonempty().optional(),
lastModifiedAt: z.string().base64().nonempty(),
lastModifiedAtIv: z.string().base64().nonempty(),
categories: z.number().int().positive().array(),
});
export type FileInfoResponse = z.output<typeof fileInfoResponse>;
export const fileRenameRequest = z.object({
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
});
export type FileRenameRequest = z.input<typeof fileRenameRequest>;
export const fileThumbnailInfoResponse = z.object({
updatedAt: z.string().datetime(),
contentIv: z.string().base64().nonempty(),
});
export type FileThumbnailInfoResponse = z.output<typeof fileThumbnailInfoResponse>;
export const fileThumbnailUploadRequest = z.object({
dekVersion: z.string().datetime(),
contentIv: z.string().base64().nonempty(),
});
export type FileThumbnailUploadRequest = z.input<typeof fileThumbnailUploadRequest>;
export const fileListResponse = z.object({
files: z.number().int().positive().array(),
});
export type FileListResponse = z.output<typeof fileListResponse>;
export const duplicateFileScanRequest = z.object({
hskVersion: z.number().int().positive(),
contentHmac: z.string().base64().nonempty(),
});
export type DuplicateFileScanRequest = z.input<typeof duplicateFileScanRequest>;
export const duplicateFileScanResponse = z.object({
files: z.number().int().positive().array(),
});
export type DuplicateFileScanResponse = z.output<typeof duplicateFileScanResponse>;
export const missingThumbnailFileScanResponse = z.object({
files: z.number().int().positive().array(),
});
export type MissingThumbnailFileScanResponse = z.output<typeof missingThumbnailFileScanResponse>;
export const fileUploadRequest = z.object({
parent: directoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
hskVersion: z.number().int().positive(),
contentHmac: z.string().base64().nonempty(),
contentType: z
.string()
.trim()
.nonempty()
.refine((value) => mime.getExtension(value) !== null), // MIME type
contentIv: z.string().base64().nonempty(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
createdAt: z.string().base64().nonempty().optional(),
createdAtIv: z.string().base64().nonempty().optional(),
lastModifiedAt: z.string().base64().nonempty(),
lastModifiedAtIv: z.string().base64().nonempty(),
});
export type FileUploadRequest = z.input<typeof fileUploadRequest>;
export const fileUploadResponse = z.object({
file: z.int().positive(),
file: z.number().int().positive(),
});
export type FileUploadResponse = z.output<typeof fileUploadResponse>;

View File

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

View File

@@ -0,0 +1,122 @@
import { error } from "@sveltejs/kit";
import argon2 from "argon2";
import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import {
upgradeSession,
deleteSession,
deleteAllOtherSessions,
registerSessionUpgradeChallenge,
consumeSessionUpgradeChallenge,
} from "$lib/server/db/session";
import { getUser, getUserByEmail, setUserPassword } from "$lib/server/db/user";
import env from "$lib/server/loadenv";
import { startSession } from "$lib/server/modules/auth";
import { verifySignature, generateChallenge } from "$lib/server/modules/crypto";
const hashPassword = async (password: string) => {
return await argon2.hash(password);
};
const verifyPassword = async (hash: string, password: string) => {
return await argon2.verify(hash, password);
};
export const changePassword = async (
userId: number,
sessionId: string,
oldPassword: string,
newPassword: string,
) => {
if (oldPassword === newPassword) {
error(400, "Same passwords");
} else if (newPassword.length < 8) {
error(400, "Too short password");
}
const user = await getUser(userId);
if (!user) {
error(500, "Invalid session id");
} else if (!(await verifyPassword(user.password, oldPassword))) {
error(403, "Invalid password");
}
await setUserPassword(userId, await hashPassword(newPassword));
await deleteAllOtherSessions(userId, sessionId);
};
export const login = async (email: string, password: string, ip: string, userAgent: string) => {
const user = await getUserByEmail(email);
if (!user || !(await verifyPassword(user.password, password))) {
error(401, "Invalid email or password");
}
return { sessionIdSigned: await startSession(user.id, ip, userAgent) };
};
export const logout = async (sessionId: string) => {
await deleteSession(sessionId);
};
export const createSessionUpgradeChallenge = async (
sessionId: string,
userId: number,
ip: string,
encPubKey: string,
sigPubKey: string,
) => {
const client = await getClientByPubKeys(encPubKey, sigPubKey);
const userClient = client ? await getUserClient(userId, client.id) : undefined;
if (!client) {
error(401, "Invalid public key(s)");
} else if (!userClient || userClient.state === "challenging") {
error(403, "Unregistered client");
}
const { answer, challenge } = await generateChallenge(32, encPubKey);
const { id } = await registerSessionUpgradeChallenge(
sessionId,
client.id,
answer.toString("base64"),
ip,
new Date(Date.now() + env.challenge.sessionUpgradeExp),
);
return { id, challenge: challenge.toString("base64") };
};
export const verifySessionUpgradeChallenge = async (
sessionId: string,
userId: number,
ip: string,
challengeId: number,
answerSig: string,
force: boolean,
) => {
const challenge = await consumeSessionUpgradeChallenge(challengeId, sessionId, ip);
if (!challenge) {
error(403, "Invalid challenge answer");
}
const client = await getClient(challenge.clientId);
if (!client) {
error(500, "Invalid challenge answer");
} else if (
!verifySignature(Buffer.from(challenge.answer, "base64"), answerSig, client.sigPubKey)
) {
error(403, "Invalid challenge answer signature");
}
try {
await upgradeSession(userId, sessionId, client.id, force);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Session not found") {
error(500, "Invalid challenge answer");
} else if (!force && e.message === "Session already exists") {
error(409, "Already logged in");
}
}
throw e;
}
};

View File

@@ -0,0 +1,133 @@
import { error } from "@sveltejs/kit";
import {
registerCategory,
getAllCategoriesByParent,
getCategory,
setCategoryEncName,
unregisterCategory,
type CategoryId,
type NewCategory,
} from "$lib/server/db/category";
import { IntegrityError } from "$lib/server/db/error";
import {
getAllFilesByCategory,
getFile,
addFileToCategory,
removeFileFromCategory,
} from "$lib/server/db/file";
import type { Ciphertext } from "$lib/server/db/schema";
export const getCategoryInformation = async (userId: number, categoryId: CategoryId) => {
const category = categoryId !== "root" ? await getCategory(userId, categoryId) : undefined;
if (category === null) {
error(404, "Invalid category id");
}
const categories = await getAllCategoriesByParent(userId, categoryId);
return {
metadata: category && {
parentId: category.parentId ?? ("root" as const),
mekVersion: category.mekVersion,
encDek: category.encDek,
dekVersion: category.dekVersion,
encName: category.encName,
},
categories: categories.map(({ id }) => id),
};
};
export const deleteCategory = async (userId: number, categoryId: number) => {
try {
await unregisterCategory(userId, categoryId);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Category not found") {
error(404, "Invalid category id");
}
throw e;
}
};
export const addCategoryFile = async (userId: number, categoryId: number, fileId: number) => {
const category = await getCategory(userId, categoryId);
const file = await getFile(userId, fileId);
if (!category) {
error(404, "Invalid category id");
} else if (!file) {
error(404, "Invalid file id");
}
try {
await addFileToCategory(fileId, categoryId);
} catch (e) {
if (e instanceof IntegrityError && e.message === "File already added to category") {
error(400, "File already added");
}
throw e;
}
};
export const getCategoryFiles = async (userId: number, categoryId: number, recurse: boolean) => {
const category = await getCategory(userId, categoryId);
if (!category) {
error(404, "Invalid category id");
}
const files = await getAllFilesByCategory(userId, categoryId, recurse);
return { files };
};
export const removeCategoryFile = async (userId: number, categoryId: number, fileId: number) => {
const category = await getCategory(userId, categoryId);
const file = await getFile(userId, fileId);
if (!category) {
error(404, "Invalid category id");
} else if (!file) {
error(404, "Invalid file id");
}
try {
await removeFileFromCategory(fileId, categoryId);
} catch (e) {
if (e instanceof IntegrityError && e.message === "File not found in category") {
error(400, "File not added");
}
throw e;
}
};
export const renameCategory = async (
userId: number,
categoryId: number,
dekVersion: Date,
newEncName: Ciphertext,
) => {
try {
await setCategoryEncName(userId, categoryId, dekVersion, newEncName);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Category not found") {
error(404, "Invalid category id");
} else if (e.message === "Invalid DEK version") {
error(400, "Invalid DEK version");
}
}
throw e;
}
};
export const createCategory = async (params: NewCategory) => {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
error(400, "Invalid DEK version");
}
try {
await registerCategory(params);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Inactive MEK version");
}
throw e;
}
};

View File

@@ -0,0 +1,96 @@
import { error } from "@sveltejs/kit";
import { unlink } from "fs/promises";
import { IntegrityError } from "$lib/server/db/error";
import {
registerDirectory,
getAllDirectoriesByParent,
getDirectory,
setDirectoryEncName,
unregisterDirectory,
getAllFilesByParent,
type DirectoryId,
type NewDirectory,
} from "$lib/server/db/file";
import type { Ciphertext } from "$lib/server/db/schema";
export const getDirectoryInformation = async (userId: number, directoryId: DirectoryId) => {
const directory = directoryId !== "root" ? await getDirectory(userId, directoryId) : undefined;
if (directory === null) {
error(404, "Invalid directory id");
}
const directories = await getAllDirectoriesByParent(userId, directoryId);
const files = await getAllFilesByParent(userId, directoryId);
return {
metadata: directory && {
parentId: directory.parentId ?? ("root" as const),
mekVersion: directory.mekVersion,
encDek: directory.encDek,
dekVersion: directory.dekVersion,
encName: directory.encName,
},
directories: directories.map(({ id }) => id),
files: files.map(({ id }) => id),
};
};
const safeUnlink = async (path: string | null) => {
if (path) {
await unlink(path).catch(console.error);
}
};
export const deleteDirectory = async (userId: number, directoryId: number) => {
try {
const files = await unregisterDirectory(userId, directoryId);
return {
files: files.map(({ id, path, thumbnailPath }) => {
safeUnlink(path); // Intended
safeUnlink(thumbnailPath); // Intended
return id;
}),
};
} catch (e) {
if (e instanceof IntegrityError && e.message === "Directory not found") {
error(404, "Invalid directory id");
}
throw e;
}
};
export const renameDirectory = async (
userId: number,
directoryId: number,
dekVersion: Date,
newEncName: Ciphertext,
) => {
try {
await setDirectoryEncName(userId, directoryId, dekVersion, newEncName);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Directory not found") {
error(404, "Invalid directory id");
} else if (e.message === "Invalid DEK version") {
error(400, "Invalid DEK version");
}
}
throw e;
}
};
export const createDirectory = async (params: NewDirectory) => {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
error(400, "Invalid DEK version");
}
try {
await registerDirectory(params);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Invalid MEK version");
}
throw e;
}
};

View File

@@ -1,17 +1,72 @@
import { error } from "@sveltejs/kit";
import { createHash } from "crypto";
import { createReadStream, createWriteStream } from "fs";
import { mkdir, stat } from "fs/promises";
import { mkdir, stat, unlink } from "fs/promises";
import { dirname } from "path";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import { v4 as uuidv4 } from "uuid";
import { FileRepo, MediaRepo, IntegrityError } from "$lib/server/db";
import { IntegrityError } from "$lib/server/db/error";
import {
registerFile,
getAllFileIds,
getAllFileIdsByContentHmac,
getFile,
setFileEncName,
unregisterFile,
getAllFileCategories,
type NewFile,
} from "$lib/server/db/file";
import {
updateFileThumbnail,
getFileThumbnail,
getMissingFileThumbnails,
} from "$lib/server/db/media";
import type { Ciphertext } from "$lib/server/db/schema";
import env from "$lib/server/loadenv";
import { safeUnlink } from "$lib/server/modules/filesystem";
export const getFileInformation = async (userId: number, fileId: number) => {
const file = await getFile(userId, fileId);
if (!file) {
error(404, "Invalid file id");
}
const categories = await getAllFileCategories(fileId);
return {
parentId: file.parentId ?? ("root" as const),
mekVersion: file.mekVersion,
encDek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
encContentIv: file.encContentIv,
encName: file.encName,
encCreatedAt: file.encCreatedAt,
encLastModifiedAt: file.encLastModifiedAt,
categories: categories.map(({ id }) => id),
};
};
const safeUnlink = async (path: string | null) => {
if (path) {
await unlink(path).catch(console.error);
}
};
export const deleteFile = async (userId: number, fileId: number) => {
try {
const { path, thumbnailPath } = await unregisterFile(userId, fileId);
safeUnlink(path); // Intended
safeUnlink(thumbnailPath); // Intended
} catch (e) {
if (e instanceof IntegrityError && e.message === "File not found") {
error(404, "Invalid file id");
}
throw e;
}
};
export const getFileStream = async (userId: number, fileId: number) => {
const file = await FileRepo.getFile(userId, fileId);
const file = await getFile(userId, fileId);
if (!file) {
error(404, "Invalid file id");
}
@@ -23,8 +78,37 @@ export const getFileStream = async (userId: number, fileId: number) => {
};
};
export const renameFile = async (
userId: number,
fileId: number,
dekVersion: Date,
newEncName: Ciphertext,
) => {
try {
await setFileEncName(userId, fileId, dekVersion, newEncName);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "File not found") {
error(404, "Invalid file id");
} else if (e.message === "Invalid DEK version") {
error(400, "Invalid DEK version");
}
}
throw e;
}
};
export const getFileThumbnailInformation = async (userId: number, fileId: number) => {
const thumbnail = await getFileThumbnail(userId, fileId);
if (!thumbnail) {
error(404, "File or its thumbnail not found");
}
return { updatedAt: thumbnail.updatedAt, encContentIv: thumbnail.encContentIv };
};
export const getFileThumbnailStream = async (userId: number, fileId: number) => {
const thumbnail = await MediaRepo.getFileThumbnail(userId, fileId);
const thumbnail = await getFileThumbnail(userId, fileId);
if (!thumbnail) {
error(404, "File or its thumbnail not found");
}
@@ -49,13 +133,7 @@ export const uploadFileThumbnail = async (
try {
await pipeline(encContentStream, createWriteStream(path, { flags: "wx", mode: 0o600 }));
const oldPath = await MediaRepo.updateFileThumbnail(
userId,
fileId,
dekVersion,
path,
encContentIv,
);
const oldPath = await updateFileThumbnail(userId, fileId, dekVersion, path, encContentIv);
safeUnlink(oldPath); // Intended
} catch (e) {
await safeUnlink(path);
@@ -71,8 +149,27 @@ export const uploadFileThumbnail = async (
}
};
export const getFileList = async (userId: number) => {
const fileIds = await getAllFileIds(userId);
return { files: fileIds };
};
export const scanDuplicateFiles = async (
userId: number,
hskVersion: number,
contentHmac: string,
) => {
const fileIds = await getAllFileIdsByContentHmac(userId, hskVersion, contentHmac);
return { files: fileIds };
};
export const scanMissingFileThumbnails = async (userId: number) => {
const fileIds = await getMissingFileThumbnails(userId);
return { files: fileIds };
};
export const uploadFile = async (
params: Omit<FileRepo.NewFile, "path" | "encContentHash">,
params: Omit<NewFile, "path" | "encContentHash">,
encContentStream: Readable,
encContentHash: Promise<string>,
) => {
@@ -104,7 +201,7 @@ export const uploadFile = async (
throw new Error("Invalid checksum");
}
const { id: fileId } = await FileRepo.registerFile({
const { id: fileId } = await registerFile({
...params,
path,
encContentHash: hash,

View File

@@ -1,5 +1,10 @@
import { callPostApi } from "$lib/hooks";
import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto";
import { trpc, isTRPCClientError } from "$trpc/client";
import type {
SessionUpgradeRequest,
SessionUpgradeResponse,
SessionUpgradeVerifyRequest,
} from "$lib/server/schemas";
export const requestSessionUpgrade = async (
encryptKeyBase64: string,
@@ -8,43 +13,27 @@ export const requestSessionUpgrade = async (
signKey: CryptoKey,
force = false,
) => {
let id, challenge;
try {
({ id, challenge } = await trpc().auth.upgrade.mutate({
encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64,
}));
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "FORBIDDEN") {
return [false, "Unregistered client"] as const;
}
return [false] as const;
}
let res = await callPostApi<SessionUpgradeRequest>("/api/auth/upgradeSession", {
encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64,
});
if (res.status === 403) return [false, "Unregistered client"] as const;
else if (!res.ok) return [false] as const;
const { id, challenge }: SessionUpgradeResponse = await res.json();
const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessageRSA(answer, signKey);
try {
await trpc().auth.verifyUpgrade.mutate({
id,
answerSig: encodeToBase64(answerSig),
force,
});
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "CONFLICT") {
return [false, "Already logged in"] as const;
}
return [false] as const;
}
return [true] as const;
res = await callPostApi<SessionUpgradeVerifyRequest>("/api/auth/upgradeSession/verify", {
id,
answerSig: encodeToBase64(answerSig),
force,
});
if (res.status === 409) return [false, "Already logged in"] as const;
else return [res.ok] as const;
};
export const requestLogout = async () => {
try {
await trpc().auth.logout.mutate();
return true;
} catch {
// TODO: Error Handling
return false;
}
const res = await callPostApi("/api/auth/logout");
return res.ok;
};

View File

@@ -1,6 +1,7 @@
import { callPostApi } from "$lib/hooks";
import { generateDataKey, wrapDataKey, encryptString } from "$lib/modules/crypto";
import type { CategoryCreateRequest, CategoryFileRemoveRequest } from "$lib/server/schemas";
import type { MasterKey } from "$lib/stores";
import { trpc } from "$trpc/client";
export const requestCategoryCreation = async (
name: string,
@@ -10,28 +11,21 @@ export const requestCategoryCreation = async (
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
try {
await trpc().category.create.mutate({
parent: parentId,
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
});
return true;
} catch {
// TODO: Error Handling
return false;
}
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,
});
return res.ok;
};
export const requestFileRemovalFromCategory = async (fileId: number, categoryId: number) => {
try {
await trpc().category.removeFile.mutate({ id: categoryId, file: fileId });
return true;
} catch {
// TODO: Error Handling
return false;
}
const res = await callPostApi<CategoryFileRemoveRequest>(
`/api/category/${categoryId}/file/remove`,
{ file: fileId },
);
return res.ok;
};

View File

@@ -1,13 +1,21 @@
import { callGetApi } from "$lib/hooks";
import { getAllFileInfos } from "$lib/indexedDB/filesystem";
import { decryptData } from "$lib/modules/crypto";
import {
getFileCache,
storeFileCache,
deleteFileCache,
downloadFile,
getFileThumbnailCache,
storeFileThumbnailCache,
deleteFileThumbnailCache,
downloadFile,
} from "$lib/modules/file";
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
import { trpc } from "$trpc/client";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
import type {
FileThumbnailInfoResponse,
FileThumbnailUploadRequest,
FileListResponse,
} from "$lib/server/schemas";
export const requestFileDownload = async (
fileId: number,
@@ -40,15 +48,30 @@ export const requestFileThumbnailUpload = async (
return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
};
export const requestDeletedFilesCleanup = async () => {
let liveFiles;
try {
liveFiles = await trpc().file.list.query();
} catch {
// TODO: Error Handling
return;
}
export const requestFileThumbnailDownload = async (fileId: number, dataKey?: CryptoKey) => {
const cache = await getFileThumbnailCache(fileId);
if (cache || !dataKey) return cache;
let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
if (!res.ok) return null;
const { contentIv: thumbnailEncryptedIv }: FileThumbnailInfoResponse = await res.json();
res = await callGetApi(`/api/file/${fileId}/thumbnail/download`);
if (!res.ok) return null;
const thumbnailEncrypted = await res.arrayBuffer();
const thumbnailBuffer = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey);
storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended
return getThumbnailUrl(thumbnailBuffer);
};
export const requestDeletedFilesCleanup = async () => {
const res = await callGetApi("/api/file/list");
if (!res.ok) return;
const { files: liveFiles }: FileListResponse = await res.json();
const liveFilesSet = new Set(liveFiles);
const maybeCachedFiles = await getAllFileInfos();

View File

@@ -1,3 +1,4 @@
import { TRPCClientError } from "@trpc/client";
import { storeMasterKeys } from "$lib/indexedDB";
import {
encodeToBase64,
@@ -10,7 +11,7 @@ import {
} from "$lib/modules/crypto";
import { requestSessionUpgrade } from "$lib/services/auth";
import { masterKeyStore, type ClientKeys } from "$lib/stores";
import { trpc, isTRPCClientError } from "$trpc/client";
import { useTRPC } from "$trpc/client";
export const requestClientRegistration = async (
encryptKeyBase64: string,
@@ -18,14 +19,16 @@ export const requestClientRegistration = async (
verifyKeyBase64: string,
signKey: CryptoKey,
) => {
const trpc = useTRPC();
try {
const { id, challenge } = await trpc().client.register.mutate({
const { id, challenge } = await trpc.client.register.mutate({
encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64,
});
const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessageRSA(answer, signKey);
await trpc().client.verify.mutate({
await trpc.client.verify.mutate({
id,
answerSig: encodeToBase64(answerSig),
});
@@ -66,9 +69,11 @@ export const requestClientRegistrationAndSessionUpgrade = async (
};
export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => {
const trpc = useTRPC();
let masterKeysWrapped;
try {
masterKeysWrapped = await trpc().mek.list.query();
masterKeysWrapped = await trpc.mek.list.query();
} catch {
// TODO: Error Handling
return false;
@@ -105,13 +110,18 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
hmacSecretWrapped: string,
signKey: CryptoKey,
) => {
const trpc = useTRPC();
try {
await trpc().mek.registerInitial.mutate({
await trpc.mek.registerInitial.mutate({
mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
});
} catch (e) {
if (isTRPCClientError(e) && (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")) {
if (
e instanceof TRPCClientError &&
(e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")
) {
return true;
}
// TODO: Error Handling
@@ -119,7 +129,7 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
}
try {
await trpc().hsk.registerInitial.mutate({
await trpc.hsk.registerInitial.mutate({
mekVersion: 1,
hsk: hmacSecretWrapped,
});

49
src/lib/stores/file.ts Normal file
View File

@@ -0,0 +1,49 @@
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<Writable<FileUploadStatus>[]>([]);
export const fileDownloadStatusStore = writable<Writable<FileDownloadStatus>[]>([]);
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);
};

View File

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

View File

@@ -1,2 +0,0 @@
type DirectoryId = "root" | number;
type CategoryId = "root" | number;

View File

@@ -1,93 +0,0 @@
type MaybePromise<T> = T | Promise<T> | HybridPromise<T>;
type HybridPromiseState<T> =
| { mode: "sync"; status: "fulfilled"; value: T }
| { mode: "sync"; status: "rejected"; reason: unknown }
| { mode: "async"; promise: Promise<T> };
export class HybridPromise<T> implements PromiseLike<T> {
private isConsumed = false;
private constructor(private readonly state: HybridPromiseState<T>) {
if (state.mode === "sync" && state.status === "rejected") {
queueMicrotask(() => {
if (!this.isConsumed) {
throw state.reason;
}
});
}
}
isSync(): boolean {
return this.state.mode === "sync";
}
toPromise(): Promise<T> {
this.isConsumed = true;
if (this.state.mode === "async") return this.state.promise;
return this.state.status === "fulfilled"
? Promise.resolve(this.state.value)
: Promise.reject(this.state.reason);
}
static resolve<T>(value: MaybePromise<T>): HybridPromise<T> {
if (value instanceof HybridPromise) return value;
return new HybridPromise(
value instanceof Promise
? { mode: "async", promise: value }
: { mode: "sync", status: "fulfilled", value },
);
}
static reject<T = never>(reason?: unknown): HybridPromise<T> {
return new HybridPromise({ mode: "sync", status: "rejected", reason });
}
then<TResult1 = T, TResult2 = never>(
onfulfilled?: ((value: T) => MaybePromise<TResult1>) | null | undefined,
onrejected?: ((reason: unknown) => MaybePromise<TResult2>) | null | undefined,
): HybridPromise<TResult1 | TResult2> {
this.isConsumed = true;
if (this.state.mode === "async") {
return new HybridPromise({
mode: "async",
promise: this.state.promise.then(onfulfilled, onrejected) as any,
});
}
try {
if (this.state.status === "fulfilled") {
if (!onfulfilled) return HybridPromise.resolve(this.state.value as any);
return HybridPromise.resolve(onfulfilled(this.state.value));
} else {
if (!onrejected) return HybridPromise.reject(this.state.reason);
return HybridPromise.resolve(onrejected(this.state.reason));
}
} catch (e) {
return HybridPromise.reject(e);
}
}
catch<TResult = never>(
onrejected?: ((reason: unknown) => MaybePromise<TResult>) | null | undefined,
): HybridPromise<T | TResult> {
return this.then<T, TResult>(null, onrejected);
}
finally(onfinally?: (() => void) | null | undefined): HybridPromise<T> {
this.isConsumed = true;
if (this.state.mode === "async") {
return new HybridPromise({ mode: "async", promise: this.state.promise.finally(onfinally) });
}
try {
onfinally?.();
return new HybridPromise(this.state);
} catch (e) {
return HybridPromise.reject(e);
}
}
}

View File

@@ -1,4 +0,0 @@
export * from "./format";
export * from "./gotoStateful";
export * from "./HybridPromise";
export * from "./sort";

View File

@@ -1,59 +0,0 @@
interface SortEntry {
name?: string;
date?: Date;
}
export enum SortBy {
NAME_ASC,
NAME_DESC,
DATE_ASC,
DATE_DESC,
}
type SortFunc = (a: SortEntry, b: SortEntry) => number;
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
const sortByNameAsc: SortFunc = ({ name: a }, { name: b }) => {
if (a && b) return collator.compare(a, b);
if (a) return -1;
if (b) return 1;
return 0;
};
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
const sortByDateAsc: SortFunc = ({ date: a }, { date: b }) => {
if (a && b) return a.getTime() - b.getTime();
if (a) return -1;
if (b) return 1;
return 0;
};
const sortByDateDesc: SortFunc = (a, b) => -sortByDateAsc(a, b);
export const sortEntries = <T extends SortEntry>(entries: T[], sortBy = SortBy.NAME_ASC) => {
let sortFunc: SortFunc;
switch (sortBy) {
case SortBy.NAME_ASC:
sortFunc = sortByNameAsc;
break;
case SortBy.NAME_DESC:
sortFunc = sortByNameDesc;
break;
case SortBy.DATE_ASC:
sortFunc = sortByDateAsc;
break;
case SortBy.DATE_DESC:
sortFunc = sortByDateDesc;
break;
default: {
const exhaustive: never = sortBy;
sortFunc = exhaustive;
}
}
entries.sort(sortFunc);
return entries;
};

View File

@@ -1,11 +1,10 @@
import { trpc } from "$trpc/client";
import { callPostApi } from "$lib/hooks";
import type { PasswordChangeRequest } from "$lib/server/schemas";
export const requestPasswordChange = async (oldPassword: string, newPassword: string) => {
try {
await trpc().auth.changePassword.mutate({ oldPassword, newPassword });
return true;
} catch {
// TODO: Error Handling
return false;
}
const res = await callPostApi<PasswordChangeRequest>("/api/auth/changePassword", {
oldPassword,
newPassword,
});
return res.ok;
};

View File

@@ -1,4 +1,5 @@
import { trpc } from "$trpc/client";
import { callPostApi } from "$lib/hooks";
import type { LoginRequest } from "$lib/server/schemas";
export { requestLogout } from "$lib/services/auth";
export { requestDeletedFilesCleanup } from "$lib/services/file";
@@ -8,11 +9,6 @@ export {
} from "$lib/services/key";
export const requestLogin = async (email: string, password: string) => {
try {
await trpc().auth.login.mutate({ email, password });
return true;
} catch {
// TODO: Error Handling
return false;
}
const res = await callPostApi<LoginRequest>("/api/auth/login", { email, password });
return res.ok;
};

View File

@@ -1,15 +1,18 @@
<script lang="ts">
import FileSaver from "file-saver";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { FullscreenDiv } from "$lib/components/atoms";
import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules";
import { getFileInfo, type FileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import {
getFileInfo,
getCategoryInfo,
type FileInfo,
type CategoryInfo,
} from "$lib/modules/filesystem";
import { captureVideoThumbnail } from "$lib/modules/thumbnail";
import { getFileDownloadState } from "$lib/modules/file";
import { masterKeyStore } from "$lib/stores";
import { HybridPromise } from "$lib/utils";
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
import DownloadStatus from "./DownloadStatus.svelte";
import {
@@ -18,43 +21,51 @@
requestThumbnailUpload,
requestFileAdditionToCategory,
} from "./service";
import TopBarMenu from "./TopBarMenu.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert";
import IconCamera from "~icons/material-symbols/camera";
import IconClose from "~icons/material-symbols/close";
import IconAddCircle from "~icons/material-symbols/add-circle";
let { data } = $props();
let info: MaybeFileInfo | undefined = $state();
let downloadState = $derived(getFileDownloadState(data.id));
let info: Writable<FileInfo | null> | undefined = $state();
let categories: Writable<CategoryInfo | null>[] = $state([]);
let isMenuOpen = $state(false);
let isAddToCategoryBottomSheetOpen = $state(false);
let downloadStatus = $derived(
$fileDownloadStatusStore.find((statusStore) => {
const { id, status } = get(statusStore);
return id === data.id && isFileDownloading(status);
}),
);
let isDownloadRequested = $state(false);
let viewerType: "image" | "video" | undefined = $state();
let fileBlob: Blob | undefined = $state();
let fileBlobUrl: string | undefined = $state();
let heicBlob: Blob | undefined = $state();
let videoElement: HTMLVideoElement | undefined = $state();
const updateViewer = async (buffer: ArrayBuffer, contentType: string) => {
fileBlob = new Blob([buffer], { type: contentType });
fileBlobUrl = URL.createObjectURL(fileBlob);
const fileBlob = new Blob([buffer], { type: contentType });
if (viewerType) {
fileBlobUrl = URL.createObjectURL(fileBlob);
heicBlob = contentType === "image/heic" ? fileBlob : undefined;
}
return fileBlob;
};
const convertHeicToJpeg = async () => {
if (fileBlob?.type !== "image/heic") return;
if (!heicBlob) return;
URL.revokeObjectURL(fileBlobUrl!);
fileBlobUrl = undefined;
const { default: heic2any } = await import("heic2any");
fileBlobUrl = URL.createObjectURL(
(await heic2any({ blob: fileBlob, toType: "image/jpeg" })) as Blob,
(await heic2any({ blob: heicBlob, toType: "image/jpeg" })) as Blob,
);
heicBlob = undefined;
};
const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => {
@@ -65,27 +76,28 @@
const addToCategory = async (categoryId: number) => {
await requestFileAdditionToCategory(data.id, categoryId);
isAddToCategoryBottomSheetOpen = false;
void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
};
const removeFromCategory = async (categoryId: number) => {
await requestFileRemovalFromCategory(data.id, categoryId);
void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
};
$effect(() => {
HybridPromise.resolve(getFileInfo(data.id, $masterKeyStore?.get(1)?.key!)).then((result) => {
if (data.id === result.id) {
info = result;
}
});
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
isDownloadRequested = false;
viewerType = undefined;
});
$effect(() => {
if (info?.dataKey) {
const contentType = info.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")) {
@@ -93,24 +105,24 @@
}
untrack(() => {
if (!downloadState && !isDownloadRequested) {
if (!downloadStatus && !isDownloadRequested) {
isDownloadRequested = true;
requestFileDownload(data.id, info!.contentIv!, info!.dataKey!.key).then(
async (buffer) => {
const blob = await updateViewer(buffer, contentType);
if (!viewerType) {
FileSaver.saveAs(blob, info!.name);
}
},
);
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => {
const blob = await updateViewer(buffer, contentType);
if (!viewerType) {
FileSaver.saveAs(blob, $info.name);
}
});
}
});
}
});
$effect(() => {
if (info?.exists && downloadState?.status === "decrypted") {
untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentIv!));
if ($info && $downloadStatus?.status === "decrypted") {
untrack(
() => !isDownloadRequested && updateViewer($downloadStatus.result!, $info.contentType),
);
}
});
@@ -121,32 +133,11 @@
<title>파일</title>
</svelte:head>
<TopBar title={info?.name}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div onclick={(e) => e.stopPropagation()}>
<button
onclick={() => (isMenuOpen = !isMenuOpen)}
class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-black active:bg-opacity-[0.04]"
>
<IconMoreVert class="text-2xl" />
</button>
<TopBarMenu
bind:isOpen={isMenuOpen}
directoryId={["category", "gallery"].includes(page.url.searchParams.get("from") ?? "")
? info?.parentId
: undefined}
{fileBlob}
filename={info?.name}
/>
</div>
</TopBar>
<TopBar title={$info?.name} />
<FullscreenDiv>
<div class="space-y-4 pb-4">
{#if downloadState}
<DownloadStatus state={downloadState} />
{/if}
{#if info && viewerType}
<DownloadStatus status={downloadStatus} />
{#if $info && viewerType}
<div class="flex w-full justify-center">
{#snippet viewerLoading(message: string)}
<p class="text-gray-500">{message}</p>
@@ -154,17 +145,18 @@
{#if viewerType === "image"}
{#if fileBlobUrl}
<img src={fileBlobUrl} alt={info.name} onerror={convertHeicToJpeg} />
<img src={fileBlobUrl} alt={$info.name} onerror={convertHeicToJpeg} />
{:else}
{@render viewerLoading("이미지를 불러오고 있어요.")}
{/if}
{:else if viewerType === "video"}
{#if fileBlobUrl}
<div class="flex flex-col space-y-2">
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={videoElement} src={fileBlobUrl} controls muted></video>
<IconEntryButton
icon={IconCamera}
onclick={() => updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)}
onclick={() => updateThumbnail($info.dataKey!, $info.dataKeyVersion!)}
class="w-full"
>
이 장면을 썸네일로 설정하기
@@ -180,7 +172,7 @@
<p class="text-lg font-bold">카테고리</p>
<div class="space-y-1">
<Categories
categories={info?.categories ?? []}
{categories}
categoryMenuIcon={IconClose}
onCategoryClick={({ id }) => goto(`/category/${id}`)}
onCategoryMenuClick={({ id }) => removeFromCategory(id)}

View File

@@ -1,10 +1,10 @@
<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 MaybeCategoryInfo } from "$lib/modules/filesystem";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { HybridPromise } from "$lib/utils";
import { requestCategoryCreation } from "./service";
interface Props {
@@ -14,36 +14,32 @@
let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props();
let categoryInfo: MaybeCategoryInfo | undefined = $state();
let category: Writable<CategoryInfo | null> | undefined = $state();
let isCategoryCreateModalOpen = $state(false);
$effect(() => {
if (isOpen) {
HybridPromise.resolve(getCategoryInfo("root", $masterKeyStore?.get(1)?.key!)).then(
(result) => (categoryInfo = result),
);
category = getCategoryInfo("root", $masterKeyStore?.get(1)?.key!);
}
});
</script>
{#if categoryInfo?.exists}
{#if $category}
<BottomSheet bind:isOpen class="flex flex-col">
<FullscreenDiv>
<SubCategories
class="py-4"
info={categoryInfo}
info={$category}
onSubCategoryClick={({ id }) =>
HybridPromise.resolve(getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)).then(
(result) => (categoryInfo = result),
)}
(category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
subCategoryCreatePosition="top"
/>
{#if categoryInfo.id !== "root"}
{#if $category.id !== "root"}
<BottomDiv>
<Button onclick={() => onAddToCategoryClick(categoryInfo!.id as number)} class="w-full">
{categoryInfo!.name} 카테고리에 추가하기
<Button onclick={() => onAddToCategoryClick($category.id)} class="w-full">
카테고리에 추가하기
</Button>
</BottomDiv>
{/if}
@@ -54,8 +50,8 @@
<CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, categoryInfo!.id, $masterKeyStore?.get(1)!)) {
void getCategoryInfo(categoryInfo!.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,31 +1,32 @@
<script lang="ts">
import { isFileDownloading, type FileDownloadState } from "$lib/modules/file";
import { formatNetworkSpeed } from "$lib/utils";
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import { isFileDownloading, type FileDownloadStatus } from "$lib/stores";
interface Props {
state: FileDownloadState;
status?: Writable<FileDownloadStatus>;
}
let { state }: Props = $props();
let { status }: Props = $props();
</script>
{#if isFileDownloading(state.status)}
{#if $status && isFileDownloading($status.status)}
<div class="w-full rounded-xl bg-gray-100 p-3">
<p class="font-medium">
{#if state.status === "download-pending"}
{#if $status.status === "download-pending"}
다운로드를 기다리는 중
{:else if state.status === "downloading"}
{:else if $status.status === "downloading"}
다운로드하는 중
{:else if state.status === "decryption-pending"}
{:else if $status.status === "decryption-pending"}
복호화를 기다리는 중
{:else if state.status === "decrypting"}
{:else if $status.status === "decrypting"}
복호화하는 중
{/if}
</p>
<p class="text-xs">
{#if state.status === "downloading"}
{#if $status.status === "downloading"}
전송됨
{Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)}
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
{/if}
</p>
</div>

View File

@@ -1,59 +0,0 @@
<script lang="ts">
import FileSaver from "file-saver";
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { fly } from "svelte/transition";
import { goto } from "$app/navigation";
import IconFolderOpen from "~icons/material-symbols/folder-open";
import IconCloudDownload from "~icons/material-symbols/cloud-download";
interface Props {
directoryId?: "root" | number;
fileBlob?: Blob;
filename?: string;
isOpen: boolean;
}
let { directoryId, fileBlob, filename, isOpen = $bindable() }: Props = $props();
</script>
<svelte:window onclick={() => (isOpen = false)} />
{#if isOpen && (directoryId || fileBlob)}
<div
class="absolute right-2 top-full z-20 space-y-1 rounded-lg bg-white px-1 py-2 shadow-2xl"
transition:fly={{ y: -8, duration: 200 }}
>
<p class="px-3 pt-2 text-sm font-semibold text-gray-600">더보기</p>
<div class="flex flex-col">
{#snippet menuButton(
Icon: Component<SvelteHTMLElements["svg"]>,
text: string,
onclick: () => void,
)}
<button {onclick} class="rounded-xl active:bg-gray-100">
<div
class="flex items-center gap-x-3 px-3 py-2 text-lg text-gray-700 transition active:scale-95"
>
<Icon />
<p class="font-medium">{text}</p>
</div>
</button>
{/snippet}
{#if directoryId}
{@render menuButton(IconFolderOpen, "폴더에서 보기", () =>
goto(
directoryId === "root" ? "/directory?from=file" : `/directory/${directoryId}?from=file`,
),
)}
{/if}
{#if fileBlob}
{@render menuButton(IconCloudDownload, "다운로드", () => {
FileSaver.saveAs(fileBlob, filename);
})}
{/if}
</div>
</div>
{/if}

View File

@@ -1,7 +1,8 @@
import { callPostApi } from "$lib/hooks";
import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file";
import type { CategoryFileAddRequest } from "$lib/server/schemas";
import { requestFileThumbnailUpload } from "$lib/services/file";
import { trpc } from "$trpc/client";
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
export { requestFileDownload } from "$lib/services/file";
@@ -22,11 +23,8 @@ export const requestThumbnailUpload = async (
};
export const requestFileAdditionToCategory = async (fileId: number, categoryId: number) => {
try {
await trpc().category.addFile.mutate({ id: categoryId, file: fileId });
return true;
} catch {
// TODO: Error Handling
return false;
}
const res = await callPostApi<CategoryFileAddRequest>(`/api/category/${categoryId}/file/add`, {
file: fileId,
});
return res.ok;
};

View File

@@ -1,31 +1,19 @@
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import {
getDownloadingFiles,
clearDownloadedFiles,
type FileDownloadState,
} from "$lib/modules/file";
import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { fileDownloadStatusStore, isFileDownloading } from "$lib/stores";
import File from "./File.svelte";
let downloadingFiles: { info: MaybeFileInfo; state: FileDownloadState }[] = $state([]);
let downloadingFiles = $derived(
$fileDownloadStatusStore.filter((status) => isFileDownloading(get(status).status)),
);
onMount(async () => {
const states = getDownloadingFiles();
const infos = await bulkGetFileInfo(
states.map(({ id }) => id),
$masterKeyStore?.get(1)?.key!,
$effect(() => () => {
$fileDownloadStatusStore = $fileDownloadStatusStore.filter((status) =>
isFileDownloading(get(status).status),
);
downloadingFiles = states.map((state) => ({
info: infos.get(state.id)!,
state,
}));
});
$effect(() => clearDownloadedFiles);
</script>
<svelte:head>
@@ -35,10 +23,8 @@
<TopBar />
<FullscreenDiv>
<div class="space-y-2 pb-4">
{#each downloadingFiles as { info, state } (info.id)}
{#if info.exists}
<File {info} {state} />
{/if}
{#each downloadingFiles as status}
<File {status} />
{/each}
</div>
</FullscreenDiv>

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import type { FileDownloadState } from "$lib/modules/file";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { formatNetworkSpeed } from "$lib/utils";
import { get, type Writable } from "svelte/store";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { formatNetworkSpeed } from "$lib/modules/util";
import { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
import IconCloud from "~icons/material-symbols/cloud";
import IconCloudDownload from "~icons/material-symbols/cloud-download";
@@ -11,49 +12,56 @@
import IconError from "~icons/material-symbols/error";
interface Props {
info: SummarizedFileInfo;
state: FileDownloadState;
status: Writable<FileDownloadStatus>;
}
let { info, state }: Props = $props();
let { status }: Props = $props();
let fileInfo: Writable<FileInfo | null> | undefined = $state();
$effect(() => {
fileInfo = getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!);
});
</script>
<div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg text-gray-600">
{#if state.status === "download-pending"}
<IconCloud />
{:else if state.status === "downloading"}
<IconCloudDownload />
{:else if state.status === "decryption-pending"}
<IconLock />
{:else if state.status === "decrypting"}
<IconLockClock />
{:else if state.status === "decrypted"}
<IconCheckCircle class="text-green-500" />
{:else if state.status === "error"}
<IconError class="text-red-500" />
{/if}
</div>
<div class="flex-grow overflow-hidden">
<p title={info.name} class="truncate font-medium">
{info.name}
</p>
<p class="text-xs text-gray-800">
{#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 $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"}
<IconCloud />
{:else if $status.status === "downloading"}
<IconCloudDownload />
{:else if $status.status === "decryption-pending"}
<IconLock />
{:else if $status.status === "decrypting"}
<IconLockClock />
{:else if $status.status === "decrypted"}
<IconCheckCircle class="text-green-500" />
{:else if $status.status === "error"}
<IconError class="text-red-500" />
{/if}
</p>
</div>
<div class="flex-grow overflow-hidden">
<p title={$fileInfo.name} class="truncate font-medium">
{$fileInfo.name}
</p>
<p class="text-xs text-gray-800">
{#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}
</p>
</div>
</div>
</div>
{/if}

View File

@@ -1,12 +1,19 @@
<script lang="ts">
import { get } from "svelte/store";
import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import { getUploadingFiles, clearUploadedFiles } from "$lib/modules/file";
import { fileUploadStatusStore, isFileUploading } from "$lib/stores";
import File from "./File.svelte";
const uploadingFiles = getUploadingFiles();
let uploadingFiles = $derived(
$fileUploadStatusStore.filter((status) => isFileUploading(get(status).status)),
);
$effect(() => clearUploadedFiles);
$effect(() => () => {
$fileUploadStatusStore = $fileUploadStatusStore.filter((status) =>
isFileUploading(get(status).status),
);
});
</script>
<svelte:head>
@@ -16,8 +23,8 @@
<TopBar />
<FullscreenDiv>
<div class="space-y-2 pb-4">
{#each uploadingFiles as file}
<File state={file} />
{#each uploadingFiles as status}
<File {status} />
{/each}
</div>
</FullscreenDiv>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { FileUploadState } from "$lib/modules/file";
import { formatNetworkSpeed } from "$lib/utils";
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import type { FileUploadStatus } from "$lib/stores";
import IconPending from "~icons/material-symbols/pending";
import IconLockClock from "~icons/material-symbols/lock-clock";
@@ -10,47 +11,45 @@
import IconError from "~icons/material-symbols/error";
interface Props {
state: FileUploadState;
status: Writable<FileUploadStatus>;
}
let { state }: Props = $props();
let { status }: Props = $props();
</script>
<div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg text-gray-600">
{#if state.status === "queued" || state.status === "encryption-pending"}
{#if $status.status === "encryption-pending"}
<IconPending />
{:else if state.status === "encrypting"}
{:else if $status.status === "encrypting"}
<IconLockClock />
{:else if state.status === "upload-pending"}
{:else if $status.status === "upload-pending"}
<IconCloud />
{:else if state.status === "uploading"}
{:else if $status.status === "uploading"}
<IconCloudUpload />
{:else if state.status === "uploaded"}
{:else if $status.status === "uploaded"}
<IconCloudDone class="text-blue-500" />
{:else if state.status === "error"}
{:else if $status.status === "error"}
<IconError class="text-red-500" />
{/if}
</div>
<div class="flex-grow overflow-hidden">
<p title={state.name} class="truncate font-medium">
{state.name}
<p title={$status.name} class="truncate font-medium">
{$status.name}
</p>
<p class="text-xs text-gray-800">
{#if state.status === "queued"}
대기 중
{:else if state.status === "encryption-pending"}
{#if $status.status === "encryption-pending"}
준비 중
{:else if state.status === "encrypting"}
{:else if $status.status === "encrypting"}
암호화하는 중
{:else if state.status === "upload-pending"}
{:else if $status.status === "upload-pending"}
업로드를 기다리는 중
{:else if state.status === "uploading"}
{:else if $status.status === "uploading"}
전송됨
{Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)}
{:else if state.status === "uploaded"}
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
{:else if $status.status === "uploaded"}
업로드 완료
{:else if state.status === "error"}
{:else if $status.status === "error"}
업로드 실패
{/if}
</p>

View File

@@ -1,7 +0,0 @@
import { createCaller } from "$trpc/router.server";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
const files = await createCaller(event).file.list();
return { files };
};

View File

@@ -1,97 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { FileThumbnailButton, FullscreenDiv, RowVirtualizer } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import {
bulkGetFileInfo,
type MaybeFileInfo,
type SummarizedFileInfo,
} from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
let { data } = $props();
type Row =
| { type: "header"; label: string }
| { type: "items"; files: SummarizedFileInfo[]; isLast: boolean };
let files: MaybeFileInfo[] = $state([]);
let rows: Row[] = $derived.by(() => {
const groups = Map.groupBy(
files
.filter((file) => file.exists)
.filter(
(file) => file.contentType.startsWith("image/") || file.contentType.startsWith("video/"),
)
.map((file) => ({ ...file, date: file.createdAt ?? file.lastModifiedAt })),
(file) => formatDateSortable(file.date),
);
return Array.from(groups.entries())
.sort(([dateA], [dateB]) => dateB.localeCompare(dateA))
.flatMap(([, entries]) => {
const sortedEntries = sortEntries([...entries], SortBy.DATE_DESC);
return [
{
type: "header",
label: formatDate(sortedEntries[0]!.date),
},
...Array.from({ length: Math.ceil(sortedEntries.length / 4) }, (_, i) => {
const start = i * 4;
const end = start + 4;
return {
type: "items" as const,
files: sortedEntries.slice(start, end),
isLast: end >= sortedEntries.length,
};
}),
];
});
});
onMount(async () => {
files = Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values());
});
</script>
<svelte:head>
<title>사진 및 동영상</title>
</svelte:head>
<TopBar title="사진 및 동영상" />
<FullscreenDiv>
<RowVirtualizer
count={rows.length}
itemHeight={(index) =>
rows[index]!.type === "header" ? 28 : 181 + (rows[index]!.isLast ? 16 : 4)}
class="flex flex-grow flex-col"
>
{#snippet item(index)}
{@const row = rows[index]!}
{#if row.type === "header"}
<p class="pb-2 text-sm font-medium">{row.label}</p>
{:else}
<div class={["grid grid-cols-4 gap-x-1", row.isLast ? "pb-4" : "pb-1"]}>
{#each row.files as file (file.id)}
<FileThumbnailButton
info={file}
onclick={() => goto(`/file/${file.id}?from=gallery`)}
/>
{/each}
</div>
{/if}
{/snippet}
{#snippet placeholder()}
<div class="flex h-full flex-grow items-center justify-center">
<p class="text-gray-500">
{#if files.length === 0}
업로드된 파일이 없어요.
{:else}
사진 또는 동영상이 없어요.
{/if}
</p>
</div>
{/snippet}
</RowVirtualizer>
</FullscreenDiv>

View File

@@ -1,5 +1,5 @@
import { error } from "@sveltejs/kit";
import { keyExportState } from "$lib/utils";
import { keyExportState } from "$lib/hooks/gotoStateful";
import type { PageLoad } from "./$types";
export const load: PageLoad = async () => {

View File

@@ -4,9 +4,9 @@
import { BottomDiv, Button, FullscreenDiv, TextButton } from "$lib/components/atoms";
import { TitledDiv } from "$lib/components/molecules";
import { ForceLoginModal } from "$lib/components/organisms";
import { gotoStateful } from "$lib/hooks";
import { storeClientKeys } from "$lib/modules/key";
import { clientKeyStore } from "$lib/stores";
import { gotoStateful } from "$lib/utils";
import Order from "./Order.svelte";
import {
generateClientKeys,

View File

@@ -1,39 +1,42 @@
<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 { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { formatFileSize } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import { formatFileSize } from "$lib/utils";
import File from "./File.svelte";
interface FileCache {
index: FileCacheIndex;
info: MaybeFileInfo;
fileInfo: Writable<FileInfo | null>;
}
let fileCache: FileCache[] | undefined = $state();
let fileCacheTotalSize = $derived(
fileCache?.reduce((acc, { index }) => acc + index.size, 0) ?? 0,
);
let fileCacheTotalSize = $state(0);
const deleteFileCache = async (fileId: number) => {
await doDeleteFileCache(fileId);
fileCache = fileCache?.filter(({ index }) => index.fileId !== fileId);
};
onMount(async () => {
const indexes = getFileCacheIndex();
const infos = await bulkGetFileInfo(
indexes.map(({ fileId }) => fileId),
$masterKeyStore?.get(1)?.key!,
);
fileCache = indexes
.map((index) => ({ index, info: infos.get(index.fileId)! }))
onMount(() => {
fileCache = getFileCacheIndex()
.map((index) => ({
index,
fileInfo: getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!),
}))
.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime());
});
$effect(() => {
if (fileCache) {
fileCacheTotalSize = fileCache.reduce((acc, { index }) => acc + index.size, 0);
}
});
</script>
<svelte:head>
@@ -52,8 +55,8 @@
<p>캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.</p>
</div>
<div class="space-y-2">
{#each fileCache as { index, info } (info.id)}
<File {index} {info} onDeleteClick={deleteFileCache} />
{#each fileCache as { index, fileInfo }}
<File {index} info={fileInfo} onDeleteClick={deleteFileCache} />
{/each}
</div>
</div>

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileCacheIndex } from "$lib/indexedDB";
import type { MaybeFileInfo } from "$lib/modules/filesystem";
import { formatDate, formatFileSize } from "$lib/utils";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatFileSize } from "$lib/modules/util";
import IconDraft from "~icons/material-symbols/draft";
import IconScanDelete from "~icons/material-symbols/scan-delete";
@@ -9,7 +10,7 @@
interface Props {
index: FileCacheIndex;
info: MaybeFileInfo;
info: Writable<FileInfo | null>;
onDeleteClick: (fileId: number) => void;
}
@@ -17,7 +18,7 @@
</script>
<div class="flex h-14 items-center gap-x-4 p-2">
{#if info.exists}
{#if $info}
<div class="flex-shrink-0 rounded-full bg-blue-100 p-1 text-xl">
<IconDraft class="text-blue-400" />
</div>
@@ -27,8 +28,8 @@
</div>
{/if}
<div class="flex-grow overflow-hidden">
{#if info.exists}
<p title={info.name} class="truncate font-medium">{info.name}</p>
{#if $info}
<p title={$info.name} class="truncate font-medium">{$info.name}</p>
{:else}
<p class="font-medium">삭제된 파일</p>
{/if}

View File

@@ -1,7 +0,0 @@
import { createCaller } from "$trpc/router.server";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
const files = await createCaller(event).file.listWithoutThumbnail();
return { files };
};

View File

@@ -1,52 +1,39 @@
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms";
import { IconEntryButton, TopBar } from "$lib/components/molecules";
import { deleteAllFileThumbnailCaches } from "$lib/modules/file";
import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { getFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { sortEntries } from "$lib/utils";
import File from "./File.svelte";
import {
getThumbnailGenerationStatus,
clearThumbnailGenerationStatuses,
persistentStates,
getGenerationStatus,
requestThumbnailGeneration,
type GenerationStatus,
} from "./service";
} from "./service.svelte";
import IconDelete from "~icons/material-symbols/delete";
let { data } = $props();
let fileInfos: MaybeFileInfo[] = $state([]);
let files = $derived(
fileInfos
.map((info) => ({
info,
status: getThumbnailGenerationStatus(info.id),
}))
.filter(
(file): file is { info: MaybeFileInfo; status: Exclude<GenerationStatus, "uploaded"> } =>
file.status !== "uploaded",
),
);
const generateAllThumbnails = () => {
files.forEach(({ info }) => {
if (info.exists) {
requestThumbnailGeneration(info);
persistentStates.files.forEach(({ info }) => {
const fileInfo = get(info);
if (fileInfo) {
requestThumbnailGeneration(fileInfo);
}
});
};
onMount(async () => {
fileInfos = sortEntries(
Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values()),
);
onMount(() => {
persistentStates.files = data.files.map((fileId) => ({
id: fileId,
info: getFileInfo(fileId, $masterKeyStore?.get(1)?.key!),
status: getGenerationStatus(fileId),
}));
});
$effect(() => clearThumbnailGenerationStatuses);
</script>
<svelte:head>
@@ -61,30 +48,28 @@
저장된 썸네일 모두 삭제하기
</IconEntryButton>
</div>
{#if files.length > 0}
{#if persistentStates.files.length > 0}
<div class="flex-grow space-y-2 bg-white p-4">
<p class="text-lg font-bold text-gray-800">썸네일이 누락된 파일</p>
<div class="space-y-4">
<p class="break-keep text-gray-800">
{files.length}개 파일의 썸네일이 존재하지 않아요.
{persistentStates.files.length}개 파일의 썸네일이 존재하지 않아요.
</p>
<div class="space-y-2">
{#each files as { info, status } (info.id)}
{#if info.exists}
<File
{info}
{status}
onclick={({ id }) => goto(`/file/${id}`)}
onGenerateThumbnailClick={requestThumbnailGeneration}
/>
{/if}
{#each persistentStates.files as { info, status }}
<File
{info}
generationStatus={status}
onclick={({ id }) => goto(`/file/${id}`)}
onGenerateThumbnailClick={requestThumbnailGeneration}
/>
{/each}
</div>
</div>
</div>
{/if}
</div>
{#if files.length > 0}
{#if persistentStates.files.length > 0}
<BottomDiv class="px-4">
<Button onclick={generateAllThumbnails} class="w-full">모두 썸네일 생성하기</Button>
</BottomDiv>

View File

@@ -0,0 +1,14 @@
import { error } from "@sveltejs/kit";
import { callPostApi } from "$lib/hooks";
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);
if (!res.ok) {
error(500, "Internal server error");
}
const { files }: MissingThumbnailFileScanResponse = await res.json();
return { files };
};

View File

@@ -10,33 +10,37 @@
</script>
<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/filesystem";
import { formatDateTime } from "$lib/utils";
import type { GenerationStatus } from "./service";
import { formatDateTime } from "$lib/modules/util";
import type { GenerationStatus } from "./service.svelte";
import IconCamera from "~icons/material-symbols/camera";
interface Props {
info: FileInfo;
onclick: (file: FileInfo) => void;
onGenerateThumbnailClick: (file: FileInfo) => void;
status: Exclude<GenerationStatus, "uploaded"> | undefined;
info: Writable<FileInfo | null>;
onclick: (selectedFile: FileInfo) => void;
onGenerateThumbnailClick: (selectedFile: FileInfo) => void;
generationStatus?: Writable<GenerationStatus>;
}
let { info, onclick, onGenerateThumbnailClick, status }: Props = $props();
let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props();
</script>
<ActionEntryButton
class="h-14"
onclick={() => onclick(info)}
actionButtonIcon={!status || status === "error" ? IconCamera : undefined}
onActionButtonClick={() => onGenerateThumbnailClick(info)}
actionButtonClass="text-gray-800"
>
{@const subtext = status
? subtexts[status]
: formatDateTime(info.createdAt ?? info.lastModifiedAt)}
<DirectoryEntryLabel type="file" name={info.name} {subtext} />
</ActionEntryButton>
{#if $info}
<ActionEntryButton
class="h-14"
onclick={() => 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)}
<DirectoryEntryLabel type="file" name={$info.name} {subtext} />
</ActionEntryButton>
{/if}

View File

@@ -0,0 +1,158 @@
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 { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail";
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
export type GenerationStatus =
| "queued"
| "generation-pending"
| "generating"
| "upload-pending"
| "uploading"
| "uploaded"
| "error";
interface File {
id: number;
info: Writable<FileInfo | null>;
status?: Writable<GenerationStatus>;
}
const workingFiles = new Map<number, Writable<GenerationStatus>>();
let queue: (() => void)[] = [];
let memoryUsage = 0;
const memoryLimit = 100 * 1024 * 1024; // 100 MiB
export const persistentStates = $state({
files: [] as File[],
});
export const getGenerationStatus = (fileId: number) => {
return workingFiles.get(fileId);
};
const generateThumbnail = limitFunction(
async (
status: Writable<GenerationStatus>,
fileBuffer: ArrayBuffer,
fileType: string,
dataKey: CryptoKey,
) => {
status.set("generating");
const thumbnail = await doGenerateThumbnail(fileBuffer, fileType);
if (!thumbnail) return null;
const thumbnailBuffer = await thumbnail.arrayBuffer();
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
status.set("upload-pending");
return { plaintext: thumbnailBuffer, ...thumbnailEncrypted };
},
{ concurrency: 4 },
);
const requestThumbnailUpload = limitFunction(
async (
status: Writable<GenerationStatus>,
fileId: number,
dataKeyVersion: Date,
thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: string },
) => {
status.set("uploading");
const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnail);
if (!res.ok) return false;
status.set("uploaded");
workingFiles.delete(fileId);
persistentStates.files = persistentStates.files.filter(({ id }) => id != fileId);
storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended
return true;
},
{ concurrency: 4 },
);
const enqueue = async (
status: Writable<GenerationStatus> | undefined,
fileInfo: FileInfo,
priority = false,
) => {
if (status) {
status.set("queued");
} else {
status = writable("queued");
workingFiles.set(fileInfo.id, status);
persistentStates.files = persistentStates.files.map((file) =>
file.id === fileInfo.id ? { ...file, status } : file,
);
}
let resolver;
const promise = new Promise((resolve) => {
resolver = resolve;
});
if (priority) {
queue = [resolver!, ...queue];
} else {
queue.push(resolver!);
}
await promise;
};
export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
let status = workingFiles.get(fileInfo.id);
if (status && get(status) !== "error") return;
if (workingFiles.values().some((status) => get(status) !== "error")) {
await enqueue(status, fileInfo);
}
while (memoryUsage >= memoryLimit) {
await enqueue(status, fileInfo, true);
}
if (status) {
status.set("generation-pending");
} else {
status = writable("generation-pending");
workingFiles.set(fileInfo.id, status);
persistentStates.files = persistentStates.files.map((file) =>
file.id === fileInfo.id ? { ...file, status } : file,
);
}
let fileSize = 0;
try {
const file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey!);
fileSize = file.byteLength;
memoryUsage += fileSize;
if (memoryUsage < memoryLimit) {
queue.shift()?.();
}
const thumbnail = await generateThumbnail(
status,
file,
fileInfo.contentType,
fileInfo.dataKey!,
);
if (
!thumbnail ||
!(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail))
) {
status.set("error");
}
} catch {
status.set("error");
} finally {
memoryUsage -= fileSize;
queue.shift()?.();
}
};

View File

@@ -1,102 +0,0 @@
import { limitFunction } from "p-limit";
import { SvelteMap } from "svelte/reactivity";
import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file";
import type { FileInfo } from "$lib/modules/filesystem";
import { Scheduler } from "$lib/modules/scheduler";
import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail";
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
export type GenerationStatus =
| "queued"
| "generation-pending"
| "generating"
| "upload-pending"
| "uploading"
| "uploaded"
| "error";
const scheduler = new Scheduler();
const statuses = new SvelteMap<number, GenerationStatus>();
export const getThumbnailGenerationStatus = (fileId: number) => {
return statuses.get(fileId);
};
export const clearThumbnailGenerationStatuses = () => {
for (const [id, status] of statuses) {
if (status === "uploaded" || status === "error") {
statuses.delete(id);
}
}
};
const generateThumbnail = limitFunction(
async (fileId: number, fileBuffer: ArrayBuffer, fileType: string, dataKey: CryptoKey) => {
statuses.set(fileId, "generating");
const thumbnail = await doGenerateThumbnail(fileBuffer, fileType);
if (!thumbnail) return null;
const thumbnailBuffer = await thumbnail.arrayBuffer();
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
statuses.set(fileId, "upload-pending");
return { plaintext: thumbnailBuffer, ...thumbnailEncrypted };
},
{ concurrency: 4 },
);
const requestThumbnailUpload = limitFunction(
async (
fileId: number,
dataKeyVersion: Date,
thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: string },
) => {
statuses.set(fileId, "uploading");
const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnail);
if (!res.ok) return false;
statuses.set(fileId, "uploaded");
storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended
return true;
},
{ concurrency: 4 },
);
export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
const status = statuses.get(fileInfo.id);
if (status) {
if (status !== "error") return;
} else {
statuses.set(fileInfo.id, "queued");
}
try {
let file: ArrayBuffer | undefined;
await scheduler.schedule(
async () => {
statuses.set(fileInfo.id, "generation-pending");
file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey?.key!);
return file.byteLength;
},
async () => {
const thumbnail = await generateThumbnail(
fileInfo.id,
file!,
fileInfo.contentType,
fileInfo.dataKey?.key!,
);
if (
!thumbnail ||
!(await requestThumbnailUpload(fileInfo.id, fileInfo.dataKey?.version!, thumbnail))
) {
statuses.set(fileInfo.id, "error");
}
},
);
} catch (e) {
statuses.set(fileInfo.id, "error");
throw e;
}
};

View File

@@ -22,7 +22,7 @@
<AdaptiveDiv class="flex justify-evenly px-4 py-2">
{#each pages as { path, label, icon: Icon }}
<button
onclick={() => goto(path, { replaceState: true })}
onclick={() => goto(path)}
class={[
"w-16 active:rounded-xl active:bg-gray-100",
!page.url.pathname.startsWith(path) && "text-gray-600",

View File

@@ -1,16 +1,13 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { CheckBox, RowVirtualizer } from "$lib/components/atoms";
import { SubCategories, TopBar } from "$lib/components/molecules";
import { CategoryCreateModal } from "$lib/components/organisms";
import { updateCategoryInfo } from "$lib/indexedDB";
import { getCategoryInfo, type MaybeCategoryInfo } from "$lib/modules/filesystem";
import { TopBar } from "$lib/components/molecules";
import { Category, CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, updateCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { HybridPromise, sortEntries } from "$lib/utils";
import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
import CategoryRenameModal from "./CategoryRenameModal.svelte";
import File from "./File.svelte";
import {
createContext,
requestCategoryCreation,
@@ -19,50 +16,33 @@
requestCategoryDeletion,
} from "./service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert";
let { data } = $props();
let context = createContext();
let info: MaybeCategoryInfo | undefined = $state();
let info: Writable<CategoryInfo | null> | undefined = $state();
let isFileRecursive: boolean | undefined = $state();
let isCategoryCreateModalOpen = $state(false);
let isCategoryMenuBottomSheetOpen = $state(false);
let isCategoryRenameModalOpen = $state(false);
let isCategoryDeleteModalOpen = $state(false);
let lastCategoryId: CategoryId | undefined = $state();
let lastIsFileRecursive: boolean | undefined = $state();
let files = $derived(
sortEntries(
info?.files
?.map((file) => ({ name: file.name, details: file }))
.filter(({ details }) => info?.isFileRecursive || !details.isRecursive) ?? [],
),
);
$effect(() => {
if (!info || info.id === "root" || info.isFileRecursive === undefined) return;
if (lastCategoryId !== info.id) {
lastCategoryId = info.id;
lastIsFileRecursive = info.isFileRecursive;
return;
}
if (lastIsFileRecursive === info.isFileRecursive) return;
lastIsFileRecursive = info.isFileRecursive;
void updateCategoryInfo(info.id, { isFileRecursive: info.isFileRecursive });
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
isFileRecursive = undefined;
});
$effect(() => {
HybridPromise.resolve(getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!)).then(
(result) => {
if (data.id === result.id) {
info = result;
}
},
);
if ($info && isFileRecursive === undefined) {
isFileRecursive = $info.isFileRecursive ?? false;
}
});
$effect(() => {
if (data.id !== "root" && $info?.isFileRecursive !== isFileRecursive) {
updateCategoryInfo(data.id as number, { isFileRecursive });
}
});
</script>
@@ -70,56 +50,26 @@
<title>카테고리</title>
</svelte:head>
{#if info?.id !== "root"}
<TopBar title={info?.name} />
{#if data.id !== "root"}
<TopBar title={$info?.name} />
{/if}
<div class="min-h-full bg-gray-100 pb-[5.5em]">
{#if info?.exists}
<div class="space-y-4">
<div class="space-y-4 bg-white p-4">
{#if info.id !== "root"}
<p class="text-lg font-bold text-gray-800">하위 카테고리</p>
{/if}
<SubCategories
{info}
onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
onSubCategoryMenuClick={(subCategory) => {
context.selectedCategory = subCategory;
isCategoryMenuBottomSheetOpen = true;
}}
subCategoryMenuIcon={IconMoreVert}
/>
</div>
{#if info.id !== "root"}
<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>
<CheckBox bind:checked={info.isFileRecursive}>
<p class="font-medium">하위 카테고리의 파일</p>
</CheckBox>
</div>
<RowVirtualizer count={files.length} itemHeight={() => 48} itemGap={4}>
{#snippet item(index)}
{@const { details } = files[index]!}
<File
info={details}
onclick={({ id }) => goto(`/file/${id}?from=category`)}
onRemoveClick={!details.isRecursive
? async ({ id }) => {
await requestFileRemovalFromCategory(id, data.id as number);
void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}
: undefined}
/>
{/snippet}
{#snippet placeholder()}
<p class="text-center text-gray-500">이 카테고리에 추가된 파일이 없어요.</p>
{/snippet}
</RowVirtualizer>
</div>
{/if}
</div>
{#if $info && isFileRecursive !== undefined}
<Category
bind:isFileRecursive
info={$info}
onFileClick={({ id }) => goto(`/file/${id}`)}
onFileRemoveClick={async ({ id }) => {
await requestFileRemovalFromCategory(id, data.id as number);
info = 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}
</div>
@@ -127,7 +77,7 @@
bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
@@ -149,7 +99,7 @@
bind:isOpen={isCategoryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (await requestCategoryRename(context.selectedCategory!, newName)) {
void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
@@ -159,7 +109,7 @@
bind:isOpen={isCategoryDeleteModalOpen}
onDeleteClick={async () => {
if (await requestCategoryDeletion(context.selectedCategory!)) {
void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { ActionModal } from "$lib/components/molecules";
import { truncateString } from "$lib/utils";
import { truncateString } from "$lib/modules/util";
import { useContext } from "./service.svelte";
interface Props {

View File

@@ -1,28 +0,0 @@
<script lang="ts">
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import { getFileThumbnail } from "$lib/modules/file";
import type { CategoryFileInfo } from "$lib/modules/filesystem";
import type { SelectedFile } from "./service.svelte";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: CategoryFileInfo;
onclick: (file: SelectedFile) => void;
onRemoveClick?: (file: SelectedFile) => void;
}
let { info, onclick, onRemoveClick }: Props = $props();
let thumbnail = $derived(getFileThumbnail(info));
</script>
<ActionEntryButton
class="h-12"
onclick={() => onclick(info)}
actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={() => onRemoveClick?.(info)}
>
<DirectoryEntryLabel type="file" thumbnail={$thumbnail} name={info.name} />
</ActionEntryButton>

View File

@@ -1,15 +1,11 @@
import { getContext, setContext } from "svelte";
import { callPostApi } from "$lib/hooks";
import { encryptString } from "$lib/modules/crypto";
import type { SelectedCategory } from "$lib/components/molecules";
import { trpc } from "$trpc/client";
import type { CategoryRenameRequest } from "$lib/server/schemas";
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
export interface SelectedFile {
id: number;
name: string;
}
export const createContext = () => {
const context = $state({
selectedCategory: undefined as SelectedCategory | undefined,
@@ -22,33 +18,17 @@ export const useContext = () => {
};
export const requestCategoryRename = async (category: SelectedCategory, newName: string) => {
if (!category.dataKey) {
// TODO: Error Handling
return false;
}
const newNameEncrypted = await encryptString(newName, category.dataKey);
const newNameEncrypted = await encryptString(newName, category.dataKey.key);
try {
await trpc().category.rename.mutate({
id: category.id,
dekVersion: category.dataKey.version,
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
return true;
} catch {
// TODO: Error Handling
return false;
}
const res = await callPostApi<CategoryRenameRequest>(`/api/category/${category.id}/rename`, {
dekVersion: category.dataKeyVersion.toISOString(),
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
return res.ok;
};
export const requestCategoryDeletion = async (category: SelectedCategory) => {
try {
await trpc().category.delete.mutate({ id: category.id });
return true;
} catch {
// TODO: Error Handling
return false;
}
const res = await callPostApi(`/api/category/${category.id}/delete`);
return res.ok;
};

View File

@@ -1,12 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { FloatingButton } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import { getDirectoryInfo, type MaybeDirectoryInfo } from "$lib/modules/filesystem";
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import { HybridPromise } from "$lib/utils";
import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries";
import DownloadStatusCard from "./DownloadStatusCard.svelte";
@@ -30,7 +29,7 @@
let { data } = $props();
let context = createContext();
let info: MaybeDirectoryInfo | undefined = $state();
let info: Writable<DirectoryInfo | null> | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state();
let duplicatedFile: File | undefined = $state();
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
@@ -43,9 +42,6 @@
let isEntryRenameModalOpen = $state(false);
let isEntryDeleteModalOpen = $state(false);
let isFromFilePage = $derived(page.url.searchParams.get("from") === "file");
let showTopBar = $derived(data.id !== "root" || isFromFilePage);
const uploadFile = () => {
const files = fileInput?.files;
if (!files || files.length === 0) return;
@@ -61,7 +57,7 @@
.then((res) => {
if (!res) return;
// TODO: FIXME
void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
})
.catch((e: Error) => {
// TODO: FIXME
@@ -79,13 +75,7 @@
});
$effect(() => {
HybridPromise.resolve(getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!)).then(
(result) => {
if (data.id === result.id) {
info = result;
}
},
);
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
</script>
@@ -96,30 +86,23 @@
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
<div class="flex h-full flex-col">
{#if showTopBar}
<TopBar title={info?.name} class="flex-shrink-0" />
{#if data.id !== "root"}
<TopBar title={$info?.name} class="flex-shrink-0" />
{/if}
{#if info?.exists}
<div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "pt-4"]}>
{#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.id}
{#key $info}
<DirectoryEntries
{info}
info={$info}
onEntryClick={({ type, id }) => goto(`/${type}/${id}`)}
onEntryMenuClick={(entry) => {
context.selectedEntry = entry;
isEntryMenuBottomSheetOpen = true;
}}
showParentEntry={isFromFilePage && info.parentId !== undefined}
onParentClick={() =>
goto(
info!.parentId === "root"
? "/directory?from=file"
: `/directory/${info!.parentId}?from=file`,
)}
/>
{/key}
</div>
@@ -148,7 +131,7 @@
bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => {
if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
@@ -182,7 +165,7 @@
bind:isOpen={isEntryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (await requestEntryRename(context.selectedEntry!, newName)) {
void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
@@ -192,7 +175,7 @@
bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => {
if (await requestEntryDeletion(context.selectedEntry!)) {
void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;

View File

@@ -1,9 +1,19 @@
<script lang="ts">
import { ActionEntryButton, RowVirtualizer } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import { getUploadingFiles, type LiveFileUploadState } from "$lib/modules/file";
import type { DirectoryInfo } from "$lib/modules/filesystem";
import { sortEntries } from "$lib/utils";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import {
getDirectoryInfo,
getFileInfo,
type DirectoryInfo,
type FileInfo,
} from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import {
fileUploadStatusStore,
isFileUploading,
masterKeyStore,
type FileUploadStatus,
} from "$lib/stores";
import File from "./File.svelte";
import SubDirectory from "./SubDirectory.svelte";
import UploadingFile from "./UploadingFile.svelte";
@@ -13,59 +23,102 @@
info: DirectoryInfo;
onEntryClick: (entry: SelectedEntry) => void;
onEntryMenuClick: (entry: SelectedEntry) => void;
onParentClick?: () => void;
showParentEntry?: boolean;
sortBy?: SortBy;
}
let {
info,
onEntryClick,
onEntryMenuClick,
onParentClick,
showParentEntry = false,
}: Props = $props();
let { info, onEntryClick, onEntryMenuClick, sortBy = SortBy.NAME_ASC }: Props = $props();
type Entry =
| { type: "parent" }
| { type: "directory"; name: string; details: (typeof info.subDirectories)[number] }
| { type: "file"; name: string; details: (typeof info.files)[number] }
| { type: "uploading-file"; name: string; details: LiveFileUploadState };
interface DirectoryEntry {
name?: string;
info: Writable<DirectoryInfo | null>;
}
const toEntry =
<T extends Exclude<Entry["type"], "parent">>(type: T) =>
(details: Extract<Entry, { type: T }>["details"]) => ({ type, name: details.name, details });
type FileEntry =
| {
type: "file";
name?: string;
info: Writable<FileInfo | null>;
}
| {
type: "uploading-file";
name: string;
info: Writable<FileUploadStatus>;
};
let entries = $derived([
...(showParentEntry ? ([{ type: "parent" }] as const) : []),
...sortEntries(info.subDirectories.map(toEntry("directory"))),
...sortEntries([
...info.files.map(toEntry("file")),
...(getUploadingFiles(info.id) as LiveFileUploadState[]).map(toEntry("uploading-file")),
]),
]);
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: 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: get(status).name,
info: status,
})),
);
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 entries.length > 0}
<RowVirtualizer count={entries.length} itemHeight={() => 56} itemGap={4} class="pb-[4.5rem]">
{#snippet item(index)}
{@const entry = entries[index]!}
{#if entry.type === "parent"}
<ActionEntryButton class="h-14" onclick={onParentClick}>
<DirectoryEntryLabel type="parent-directory" name=".." />
</ActionEntryButton>
{:else if entry.type === "directory"}
<SubDirectory
info={entry.details}
onclick={onEntryClick}
onOpenMenuClick={onEntryMenuClick}
/>
{:else if entry.type === "file"}
<File info={entry.details} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{#if subDirectories.length + files.length > 0}
<div class="space-y-1 pb-[4.5rem]">
{#each subDirectories as { info }}
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{/each}
{#each files as file}
{#if file.type === "file"}
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else}
<UploadingFile state={entry.details} />
<UploadingFile status={file.info} />
{/if}
{/snippet}
</RowVirtualizer>
{/each}
</div>
{:else}
<div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">폴더가 비어 있어요.</p>

View File

@@ -1,38 +1,66 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import { getFileThumbnail } from "$lib/modules/file";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/utils";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/modules/util";
import { requestFileThumbnailDownload } from "./service";
import type { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert";
interface Props {
info: SummarizedFileInfo;
onclick: (entry: SelectedEntry) => void;
onOpenMenuClick: (entry: SelectedEntry) => void;
info: Writable<FileInfo | null>;
onclick: (selectedEntry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void;
}
let { info, onclick, onOpenMenuClick }: Props = $props();
let thumbnail = $derived(getFileThumbnail(info));
let thumbnail: string | undefined = $state();
const action = (callback: typeof onclick) => {
callback({ type: "file", id: info.id, dataKey: info.dataKey, name: info.name });
const openFile = () => {
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!;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script>
<ActionEntryButton
class="h-14"
onclick={() => action(onclick)}
actionButtonIcon={IconMoreVert}
onActionButtonClick={() => action(onOpenMenuClick)}
>
<DirectoryEntryLabel
type="file"
thumbnail={$thumbnail}
name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
/>
</ActionEntryButton>
{#if $info}
<ActionEntryButton
class="h-14"
onclick={openFile}
actionButtonIcon={IconMoreVert}
onActionButtonClick={openMenu}
>
<DirectoryEntryLabel
type="file"
{thumbnail}
name={$info.name}
subtext={formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
/>
</ActionEntryButton>
{/if}

View File

@@ -1,29 +1,44 @@
<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/filesystem";
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;
onclick: (entry: SelectedEntry) => void;
onOpenMenuClick: (entry: SelectedEntry) => void;
info: Writable<DirectoryInfo | null>;
onclick: (selectedEntry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void;
}
let { info, onclick, onOpenMenuClick }: Props = $props();
const action = (callback: typeof onclick) => {
callback({ type: "directory", id: info.id, dataKey: info.dataKey, name: info.name });
const openDirectory = () => {
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 as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
};
</script>
<ActionEntryButton
class="h-14"
onclick={() => action(onclick)}
actionButtonIcon={IconMoreVert}
onActionButtonClick={() => action(onOpenMenuClick)}
>
<DirectoryEntryLabel type="directory" name={info.name} />
</ActionEntryButton>
{#if $info}
<ActionEntryButton
class="h-14"
onclick={openDirectory}
actionButtonIcon={IconMoreVert}
onActionButtonClick={openMenu}
>
<DirectoryEntryLabel type="directory" name={$info.name!} />
</ActionEntryButton>
{/if}

View File

@@ -1,37 +1,38 @@
<script lang="ts">
import type { LiveFileUploadState } from "$lib/modules/file";
import { formatNetworkSpeed } from "$lib/utils";
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 {
state: LiveFileUploadState;
status: Writable<FileUploadStatus>;
}
let { state }: Props = $props();
let { status }: Props = $props();
</script>
<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" />
{#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>
<p class="text-xs">
{#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}
</p>
</div>
</div>
<div class="flex flex-grow flex-col overflow-hidden text-gray-800">
<p title={state.name} class="truncate font-medium">
{state.name}
</p>
<p class="text-xs">
{#if state.status === "queued"}
대기 중
{:else 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}
</p>
</div>
</div>
{/if}

View File

@@ -0,0 +1 @@
export { requestFileThumbnailDownload } from "$lib/services/file";

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { getDownloadingFiles } from "$lib/modules/file";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { fileDownloadStatusStore, isFileDownloading, type FileDownloadStatus } from "$lib/stores";
interface Props {
onclick: () => void;
@@ -7,7 +9,23 @@
let { onclick }: Props = $props();
let downloadingFiles = $derived(getDownloadingFiles());
let downloadingFiles: Writable<FileDownloadStatus>[] = $state([]);
$effect(() => {
downloadingFiles = $fileDownloadStatusStore.filter((status) =>
isFileDownloading(get(status).status),
);
return untrack(() => {
const unsubscribes = downloadingFiles.map((downloadingFile) =>
downloadingFile.subscribe(({ status }) => {
if (!isFileDownloading(status)) {
downloadingFiles = downloadingFiles.filter((file) => file !== downloadingFile);
}
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
{#if downloadingFiles.length > 0}

Some files were not shown because too many files have changed in this diff Show More