7 Commits

147 changed files with 4473 additions and 3740 deletions

View File

@@ -2,7 +2,11 @@
FROM node:22-alpine AS base
WORKDIR /app
RUN npm install -g pnpm@10
RUN apk add --no-cache bash curl && \
curl -o /usr/local/bin/wait-for-it https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh && \
chmod +x /usr/local/bin/wait-for-it
RUN npm install -g pnpm@9
COPY pnpm-lock.yaml .
# Build Stage
@@ -25,4 +29,4 @@ COPY --from=build /app/build ./build
EXPOSE 3000
ENV BODY_SIZE_LIMIT=Infinity
CMD ["node", "./build/index.js"]
CMD ["bash", "-c", "wait-for-it ${DATABASE_HOST:-localhost}:${DATABASE_PORT:-5432} -- node ./build/index.js"]

View File

@@ -3,8 +3,7 @@ services:
build: .
restart: unless-stopped
depends_on:
database:
condition: service_healthy
- database
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
volumes:
- ./data/library:/app/data/library
@@ -36,8 +35,3 @@ services:
environment:
- POSTGRES_USER=arkvault
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5

View File

@@ -1,24 +1,23 @@
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
import svelteQuery from "@tanstack/eslint-plugin-query";
import prettier from "eslint-config-prettier";
import svelte from "eslint-plugin-svelte";
import tailwind from "eslint-plugin-tailwindcss";
import globals from "globals";
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,19 +25,14 @@ 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,
},
},
},
...svelteQuery.configs["flat/recommended"],
);

View File

@@ -1,7 +1,7 @@
{
"name": "arkvault",
"private": true,
"version": "0.6.0",
"version": "0.5.1",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -16,57 +16,55 @@
"db:migrate": "kysely migrate"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@iconify-json/material-symbols": "^1.2.50",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tanstack/svelte-virtual": "^3.13.13",
"@trpc/client": "^11.8.1",
"@eslint/compat": "^1.3.1",
"@iconify-json/material-symbols": "^1.2.29",
"@sveltejs/adapter-node": "^5.2.13",
"@sveltejs/kit": "^2.22.5",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@tanstack/svelte-query": "^5.83.0",
"@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.8",
"@types/pg": "^8.16.0",
"autoprefixer": "^10.4.23",
"axios": "^1.13.2",
"dexie": "^4.2.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.1",
"eslint-plugin-tailwindcss": "^3.18.2",
"exifreader": "^4.33.1",
"@types/pg": "^8.15.4",
"autoprefixer": "^10.4.21",
"axios": "^1.10.0",
"dexie": "^4.0.11",
"eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.10.1",
"eslint-plugin-tailwindcss": "^3.18.0",
"exifreader": "^4.31.1",
"file-saver": "^2.0.5",
"globals": "^16.5.0",
"globals": "^16.3.0",
"heic2any": "^0.0.4",
"kysely-ctl": "^0.19.0",
"lru-cache": "^11.2.4",
"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",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.1",
"unplugin-icons": "^22.5.0",
"vite": "^7.3.0"
"kysely-ctl": "^0.13.1",
"lru-cache": "^11.1.0",
"mime": "^4.0.7",
"p-limit": "^6.2.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.35.6",
"svelte-check": "^4.2.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.36.0",
"unplugin-icons": "^22.1.0",
"vite": "^5.4.19"
},
"dependencies": {
"@fastify/busboy": "^3.2.0",
"@trpc/server": "^11.8.1",
"argon2": "^0.44.0",
"kysely": "^0.28.9",
"@fastify/busboy": "^3.1.1",
"argon2": "^0.43.0",
"kysely": "^0.28.2",
"ms": "^2.1.3",
"node-schedule": "^2.1.1",
"pg": "^8.16.3",
"superjson": "^2.2.6",
"uuid": "^13.0.0",
"zod": "^4.2.1"
"uuid": "^11.1.0",
"zod": "^3.25.76"
},
"engines": {
"node": "^22.0.0",
"pnpm": "^10.0.0"
"pnpm": "^9.0.0"
}
}

2173
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +0,0 @@
<script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import { untrack, type Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
class?: ClassValue;
count: number;
item: Snippet<[index: number]>;
itemHeight: (index: number) => number;
placeholder?: Snippet;
}
let { class: className, count, item, itemHeight, placeholder }: Props = $props();
const virtualizer = $derived(
createWindowVirtualizer({
count: untrack(() => count),
estimateSize: itemHeight,
}),
);
const measureItem = (node: HTMLElement) => {
$effect(() => $virtualizer.measureElement(node));
};
$effect(() => $virtualizer.setOptions({ count }));
</script>
<div 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}px)"
data-index={virtualItem.index}
use:measureItem
>
{@render item(virtualItem.index)}
</div>
{/each}
</div>
{#if placeholder && $virtualizer.getVirtualItems().length === 0}
{@render placeholder()}
{/if}
</div>

View File

@@ -1,42 +0,0 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file";
interface Props {
info: Writable<FileInfo | null>;
onclick?: (file: FileInfo) => void;
}
let { info, onclick }: Props = $props();
let thumbnail: string | undefined = $state();
$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}
<button
onclick={() => onclick?.($info)}
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>
{/if}

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

View File

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

View File

@@ -1,10 +1,8 @@
<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 { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import type { CategoryInfo } from "$lib/modules/filesystem2";
import IconAddCircle from "~icons/material-symbols/add-circle";
@@ -27,14 +25,6 @@
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]}>
@@ -55,7 +45,7 @@
{/if}
{#key info}
<Categories
categories={subCategories}
categoryIds={info.subCategoryIds}
categoryMenuIcon={subCategoryMenuIcon}
onCategoryClick={onSubCategoryClick}
onCategoryMenuClick={onSubCategoryMenuClick}

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

@@ -1,11 +1,10 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { CheckBox, RowVirtualizer } from "$lib/components/atoms";
import { derived } 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 { getFileInfo, type CategoryInfo } from "$lib/modules/filesystem2";
import { SortBy, sortEntries } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import { SortBy, sortEntries } from "$lib/utils";
import File from "./File.svelte";
import type { SelectedFile } from "./service";
@@ -19,7 +18,7 @@
onSubCategoryCreateClick: () => void;
onSubCategoryMenuClick: (subCategory: SelectedCategory) => void;
sortBy?: SortBy;
isFileRecursive: boolean;
isFileRecursive?: boolean;
}
let {
@@ -33,39 +32,35 @@
isFileRecursive = $bindable(),
}: Props = $props();
let files: { name?: string; info: Writable<FileInfo | null>; isRecursive: boolean }[] = $state(
[],
let fileInfos = $derived(
info.files
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive)
.map(({ id, isRecursive }) => ({
info: getFileInfo(id, $masterKeyStore?.get(1)?.key!),
isRecursive,
})) ?? [],
);
let files = $derived(
derived(
fileInfos.map(({ info }) => info),
(infos) => {
const files = infos
.map(($info, i) => {
if ($info.status === "success") {
return {
name: $info.data.name,
isRecursive: fileInfos[i]!.isRecursive,
info: $info.data,
};
}
return undefined;
})
.filter((info) => info !== undefined);
sortEntries(files, sortBy);
return files;
},
),
);
$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">
@@ -85,30 +80,25 @@
<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>
{#if isFileRecursive !== undefined}
<CheckBox bind:checked={isFileRecursive}>
<p class="font-medium">하위 카테고리의 파일</p>
</CheckBox>
{/if}
</div>
<div class="space-y-1">
{#key info}
{#each $files as { info, isRecursive }}
<File
{info}
onclick={onFileClick}
onRemoveClick={!isRecursive ? onFileRemoveClick : undefined}
/>
{:else}
<p class="text-gray-500 text-center">이 카테고리에 추가된 파일이 없어요.</p>
{/each}
{/key}
</div>
{#key info}
<RowVirtualizer
count={files.length}
itemHeight={(index) => 48 + (index + 1 < files.length ? 4 : 0)}
>
{#snippet item(index)}
{@const { info, isRecursive } = files[index]!}
<div class={[index + 1 < files.length && "pb-1"]}>
<File
{info}
onclick={onFileClick}
onRemoveClick={!isRecursive ? onFileRemoveClick : undefined}
/>
</div>
{/snippet}
{#snippet placeholder()}
<p class="text-center text-gray-500">이 카테고리에 추가된 파일이 없어요.</p>
{/snippet}
</RowVirtualizer>
{/key}
</div>
{/if}
</div>

View File

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

View File

@@ -1,134 +0,0 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { FileThumbnailButton, RowVirtualizer } from "$lib/components/atoms";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
interface Props {
files: Writable<FileInfo | null>[];
onFileClick?: (file: FileInfo) => void;
}
let { files, onFileClick }: Props = $props();
type FileEntry =
| { date?: undefined; contentType?: undefined; info: Writable<FileInfo | null> }
| { date: Date; contentType: string; info: Writable<FileInfo | null> };
type Row =
| { type: "header"; label: string }
| { type: "items"; items: FileEntry[]; isLast: boolean };
let filesWithDate: FileEntry[] = $state([]);
let rows: Row[] = $state([]);
$effect(() => {
filesWithDate = files.map((file) => {
const info = get(file);
if (info) {
return {
date: info.createdAt ?? info.lastModifiedAt,
contentType: info.contentType,
info: file,
};
} else {
return { info: file };
}
});
const buildRows = () => {
const map = new Map<string, FileEntry[]>();
for (const file of filesWithDate) {
if (
!file.date ||
!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))
) {
continue;
}
const date = formatDateSortable(file.date);
const entries = map.get(date) ?? [];
entries.push(file);
map.set(date, entries);
}
const newRows: Row[] = [];
const sortedDates = Array.from(map.keys()).sort((a, b) => b.localeCompare(a));
for (const date of sortedDates) {
const entries = map.get(date)!;
sortEntries(entries, SortBy.DATE_DESC);
newRows.push({
type: "header",
label: formatDate(entries[0]!.date!),
});
for (let i = 0; i < entries.length; i += 4) {
newRows.push({
type: "items",
items: entries.slice(i, i + 4),
isLast: i + 4 >= entries.length,
});
}
}
rows = newRows;
};
return untrack(() => {
buildRows();
const unsubscribes = filesWithDate.map((file) =>
file.info.subscribe((value) => {
const newDate = value?.createdAt ?? value?.lastModifiedAt;
const newContentType = value?.contentType;
if (file.date?.getTime() === newDate?.getTime() && file.contentType === newContentType) {
return;
}
file.date = newDate;
file.contentType = newContentType;
buildRows();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
<RowVirtualizer
count={rows.length}
itemHeight={(index) =>
rows[index]!.type === "header"
? 28
: Math.ceil(rows[index]!.items.length / 4) * 181 +
(Math.ceil(rows[index]!.items.length / 4) - 1) * 4 +
16}
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.items as { info }}
<FileThumbnailButton {info} onclick={onFileClick} />
{/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 filesWithDate.length === 0}
파일 목록을 불러오고 있어요.
{:else}
사진 또는 동영상이 없어요.
{/if}
</p>
</div>
{/snippet}
</RowVirtualizer>

View File

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

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

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

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

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

View File

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

View File

@@ -5,6 +5,7 @@ import { writable, type Writable } from "svelte/store";
import {
encodeToBase64,
generateDataKey,
makeAESKeyNonextractable,
wrapDataKey,
encryptData,
encryptString,
@@ -13,6 +14,8 @@ import {
} from "$lib/modules/crypto";
import { generateThumbnail } from "$lib/modules/thumbnail";
import type {
DuplicateFileScanRequest,
DuplicateFileScanResponse,
FileThumbnailUploadRequest,
FileUploadRequest,
FileUploadResponse,
@@ -23,17 +26,18 @@ import {
type HmacSecret,
type FileUploadStatus,
} from "$lib/stores";
import { trpc } from "$trpc/client";
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 {
@@ -115,12 +119,14 @@ const encryptFile = limitFunction(
});
return {
dataKey: await makeAESKeyNonextractable(dataKey),
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAt,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail: thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted },
@@ -173,9 +179,7 @@ export const uploadFile = async (
hmacSecret: HmacSecret,
masterKey: MasterKey,
onDuplicate: () => Promise<boolean>,
): Promise<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
> => {
) => {
const status = writable<FileUploadStatus>({
name: file.name,
parentId,
@@ -205,12 +209,14 @@ export const uploadFile = async (
}
const {
dataKey,
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAt,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail,
@@ -253,7 +259,16 @@ export const uploadFile = async (
}
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
return {
fileId,
fileDataKey: dataKey,
fileDataKeyVersion: dataKeyVersion,
fileType,
fileEncryptedIv: fileEncrypted.iv,
fileCreatedAt: createdAt,
fileBuffer,
thumbnailBuffer: thumbnail?.plaintext,
};
} catch (e) {
status.update((value) => {
value.status = "error";

View File

@@ -1,369 +0,0 @@
import { get, writable, type Writable } from "svelte/store";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
type DirectoryId,
type CategoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import { trpc, isTRPCClientError } from "$trpc/client";
export type DirectoryInfo =
| {
id: "root";
parentId?: undefined;
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
parentId: DirectoryId;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
export type CategoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subCategoryIds: number[];
files?: undefined;
isFileRecursive?: undefined;
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subCategoryIds: number[];
files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
};
const directoryInfoStore = new Map<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,
parentId: directory.parentId,
name: directory.name,
subDirectoryIds,
fileIds,
});
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
let data;
try {
data = await trpc().directory.get.query({ id });
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteDirectoryInfo(id as number);
return;
}
throw new Error("Failed to fetch directory information");
}
const { metadata, subDirectories: subDirectoryIds, files: fileIds } = data;
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
info.set({
id,
parentId: metadata!.parent,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
});
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
}
};
const fetchDirectoryInfo = async (
id: DirectoryId,
info: Writable<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,
) => {
let metadata;
try {
metadata = await trpc().file.get.query({ id });
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteFileInfo(id);
return;
}
throw new Error("Failed to fetch file information");
}
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
const createdAt =
metadata.createdAt && metadata.createdAtIv
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
: undefined;
const lastModifiedAt = await decryptDate(
metadata.lastModifiedAt,
metadata.lastModifiedAtIv,
dataKey,
);
info.set({
id,
parentId: metadata.parent,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
};
const fetchFileInfo = async (id: number, info: Writable<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 data;
try {
data = await trpc().category.get.query({ id });
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteCategoryInfo(id as number);
return;
}
throw new Error("Failed to fetch category information");
}
const { metadata, subCategories } = data;
if (id === "root") {
info.set({ id, subCategoryIds: subCategories });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
let files;
try {
files = await trpc().category.files.query({ id, recurse: true });
} catch {
throw new Error("Failed to fetch category files");
}
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
let isFileRecursive: boolean | undefined = undefined;
info.update((value) => {
const newValue = {
isFileRecursive: false,
...value,
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subCategoryIds: subCategories,
files: filesMapped,
};
isFileRecursive = newValue.isFileRecursive;
return newValue;
});
await storeCategoryInfo({
id,
parentId: metadata!.parent,
name,
files: filesMapped,
isFileRecursive: isFileRecursive!,
});
}
};
const fetchCategoryInfo = async (
id: CategoryId,
info: Writable<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

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

View File

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

View File

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

View File

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

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

@@ -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

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

View File

@@ -98,6 +98,22 @@ export const createUserClient = async (userId: number, clientId: number) => {
}
};
export const getAllUserClients = async (userId: number) => {
const userClients = await db
.selectFrom("user_client")
.selectAll()
.where("user_id", "=", userId)
.execute();
return userClients.map(
({ user_id, client_id, state }) =>
({
userId: user_id,
clientId: client_id,
state,
}) satisfies UserClient,
);
};
export const getUserClient = async (userId: number, clientId: number) => {
const userClient = await db
.selectFrom("user_client")

View File

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

View File

@@ -1,10 +0,0 @@
export * as CategoryRepo from "./category";
export * as ClientRepo from "./client";
export * as FileRepo from "./file";
export * as HskRepo from "./hsk";
export * as MediaRepo from "./media";
export * as MekRepo from "./mek";
export * as SessionRepo from "./session";
export * as UserRepo from "./user";
export * from "./error";

View File

@@ -60,6 +60,19 @@ export const registerInitialMek = async (
});
};
export const getInitialMek = async (userId: number) => {
const mek = await db
.selectFrom("master_encryption_key")
.selectAll()
.where("user_id", "=", userId)
.where("version", "=", 1)
.limit(1)
.executeTakeFirst();
return mek
? ({ userId: mek.user_id, version: mek.version, state: mek.state } satisfies Mek)
: null;
};
export const getAllValidClientMeks = async (userId: number, clientId: number) => {
const clientMeks = await db
.selectFrom("client_master_encryption_key")

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") {
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,25 +1,20 @@
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;
}
export interface ClientSession extends Session {
interface ClientSession extends Session {
clientId: number;
}
export type SessionPermission =
| "any"
| "notClient"
| "anyClient"
| "pendingClient"
| "activeClient";
export class AuthenticationError extends Error {
constructor(
public status: 400 | 401,
@@ -30,22 +25,11 @@ export class AuthenticationError extends Error {
}
}
export class AuthorizationError extends Error {
constructor(
public status: 403 | 500,
message: string,
) {
super(message);
this.name = "AuthorizationError";
}
}
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 +38,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,
@@ -68,12 +52,34 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
}
};
export const authorizeInternal = async (
export async function authorize(locals: App.Locals, requiredPermission: "any"): Promise<Session>;
export async function authorize(
locals: App.Locals,
requiredPermission: SessionPermission,
): Promise<Session> => {
requiredPermission: "notClient",
): Promise<Session>;
export async function authorize(
locals: App.Locals,
requiredPermission: "anyClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "pendingClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "activeClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "any" | "notClient" | "anyClient" | "pendingClient" | "activeClient",
): Promise<Session> {
if (!locals.session) {
throw new AuthorizationError(500, "Unauthenticated");
error(500, "Unauthenticated");
}
const { id: sessionId, userId, clientId } = locals.session;
@@ -83,63 +89,39 @@ export const authorizeInternal = async (
break;
case "notClient":
if (clientId) {
throw new AuthorizationError(403, "Forbidden");
error(403, "Forbidden");
}
break;
case "anyClient":
if (!clientId) {
throw new AuthorizationError(403, "Forbidden");
error(403, "Forbidden");
}
break;
case "pendingClient": {
if (!clientId) {
throw new AuthorizationError(403, "Forbidden");
error(403, "Forbidden");
}
const userClient = await ClientRepo.getUserClient(userId, clientId);
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
throw new AuthorizationError(500, "Invalid session id");
error(500, "Invalid session id");
} else if (userClient.state !== "pending") {
throw new AuthorizationError(403, "Forbidden");
error(403, "Forbidden");
}
break;
}
case "activeClient": {
if (!clientId) {
throw new AuthorizationError(403, "Forbidden");
error(403, "Forbidden");
}
const userClient = await ClientRepo.getUserClient(userId, clientId);
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
throw new AuthorizationError(500, "Invalid session id");
error(500, "Invalid session id");
} else if (userClient.state !== "active") {
throw new AuthorizationError(403, "Forbidden");
error(403, "Forbidden");
}
break;
}
}
return { sessionId, userId, clientId };
};
export async function authorize(
locals: App.Locals,
requiredPermission: "any" | "notClient",
): Promise<Session>;
export async function authorize(
locals: App.Locals,
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: SessionPermission,
): Promise<Session> {
try {
return await authorizeInternal(locals, requiredPermission);
} catch (e) {
if (e instanceof AuthorizationError) {
error(e.status, e.message);
}
throw e;
}
}

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,25 @@
import { error } from "@sveltejs/kit";
import { getUserClientWithDetails } from "$lib/server/db/client";
import { getInitialMek } from "$lib/server/db/mek";
import { verifySignature } from "$lib/server/modules/crypto";
export const isInitialMekNeeded = async (userId: number) => {
const initialMek = await getInitialMek(userId);
return !initialMek;
};
export const verifyClientEncMekSig = async (
userId: number,
clientId: number,
version: number,
encMek: string,
encMekSig: string,
) => {
const userClient = await getUserClientWithDetails(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
}
const data = JSON.stringify({ version, key: encMek });
return verifySignature(Buffer.from(data), encMekSig, userClient.sigPubKey);
};

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,60 @@
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>;
export const categoryCreateResponse = z.object({
category: z.number().int().positive(),
});
export type CategoryCreateResponse = z.output<typeof categoryCreateResponse>;

View File

@@ -0,0 +1,36 @@
import { z } from "zod";
export const clientListResponse = z.object({
clients: z.array(
z.object({
id: z.number().int().positive(),
state: z.enum(["pending", "active"]),
}),
),
});
export type ClientListResponse = z.output<typeof clientListResponse>;
export const clientRegisterRequest = z.object({
encPubKey: z.string().base64().nonempty(),
sigPubKey: z.string().base64().nonempty(),
});
export type ClientRegisterRequest = z.input<typeof clientRegisterRequest>;
export const clientRegisterResponse = z.object({
id: z.number().int().positive(),
challenge: z.string().base64().nonempty(),
});
export type ClientRegisterResponse = z.output<typeof clientRegisterResponse>;
export const clientRegisterVerifyRequest = z.object({
id: z.number().int().positive(),
answerSig: z.string().base64().nonempty(),
});
export type ClientRegisterVerifyRequest = z.input<typeof clientRegisterVerifyRequest>;
export const clientStatusResponse = z.object({
id: z.number().int().positive(),
state: z.enum(["pending", "active"]),
isInitialMekNeeded: z.boolean(),
});
export type ClientStatusResponse = z.output<typeof clientStatusResponse>;

View File

@@ -1,3 +1,47 @@
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({
deletedDirectories: z.number().int().positive().array(),
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>;
export const directoryCreateResponse = z.object({
directory: z.number().int().positive(),
});
export type DirectoryCreateResponse = z.output<typeof directoryCreateResponse>;

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

@@ -0,0 +1,19 @@
import { z } from "zod";
export const hmacSecretListResponse = z.object({
hsks: z.array(
z.object({
version: z.number().int().positive(),
state: z.enum(["active"]),
mekVersion: z.number().int().positive(),
hsk: z.string().base64().nonempty(),
}),
),
});
export type HmacSecretListResponse = z.output<typeof hmacSecretListResponse>;
export const initialHmacSecretRegisterRequest = z.object({
mekVersion: z.number().int().positive(),
hsk: z.string().base64().nonempty(),
});
export type InitialHmacSecretRegisterRequest = z.input<typeof initialHmacSecretRegisterRequest>;

View File

@@ -1,3 +1,8 @@
export * from "./auth";
export * from "./category";
export * from "./client";
export * from "./directory";
export * from "./file";
export * from "./hsk";
export * from "./mek";
export * from "./user";

View File

@@ -0,0 +1,19 @@
import { z } from "zod";
export const masterKeyListResponse = z.object({
meks: z.array(
z.object({
version: z.number().int().positive(),
state: z.enum(["active", "retired"]),
mek: z.string().base64().nonempty(),
mekSig: z.string().base64().nonempty(),
}),
),
});
export type MasterKeyListResponse = z.output<typeof masterKeyListResponse>;
export const initialMasterKeyRegisterRequest = z.object({
mek: z.string().base64().nonempty(),
mekSig: z.string().base64().nonempty(),
});
export type InitialMasterKeyRegisterRequest = z.input<typeof initialMasterKeyRegisterRequest>;

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export const userInfoResponse = z.object({
email: z.string().email(),
nickname: z.string().nonempty(),
});
export type UserInfoResponse = z.output<typeof userInfoResponse>;
export const nicknameChangeRequest = z.object({
newNickname: z.string().trim().min(2).max(8),
});
export type NicknameChangeRequest = z.input<typeof nicknameChangeRequest>;

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,134 @@
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 {
const { id } = await registerCategory(params);
return { id };
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Inactive MEK version");
}
throw e;
}
};

View File

@@ -0,0 +1,116 @@
import { error } from "@sveltejs/kit";
import {
createClient,
getClient,
getClientByPubKeys,
createUserClient,
getAllUserClients,
getUserClient,
setUserClientStateToPending,
registerUserClientChallenge,
consumeUserClientChallenge,
} from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto";
import { isInitialMekNeeded } from "$lib/server/modules/mek";
import env from "$lib/server/loadenv";
export const getUserClientList = async (userId: number) => {
const userClients = await getAllUserClients(userId);
return {
userClients: userClients.map(({ clientId, state }) => ({
id: clientId,
state: state as "pending" | "active",
})),
};
};
const expiresAt = () => new Date(Date.now() + env.challenge.userClientExp);
const createUserClientChallenge = async (
ip: string,
userId: number,
clientId: number,
encPubKey: string,
) => {
const { answer, challenge } = await generateChallenge(32, encPubKey);
const { id } = await registerUserClientChallenge(
userId,
clientId,
answer.toString("base64"),
ip,
expiresAt(),
);
return { id, challenge: challenge.toString("base64") };
};
export const registerUserClient = async (
userId: number,
ip: string,
encPubKey: string,
sigPubKey: string,
) => {
const client = await getClientByPubKeys(encPubKey, sigPubKey);
if (client) {
try {
await createUserClient(userId, client.id);
return await createUserClientChallenge(ip, userId, client.id, encPubKey);
} catch (e) {
if (e instanceof IntegrityError && e.message === "User client already exists") {
error(409, "Client already registered");
}
throw e;
}
} else {
if (encPubKey === sigPubKey) {
error(400, "Same public keys");
} else if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) {
error(400, "Invalid public key(s)");
}
try {
const { id: clientId } = await createClient(encPubKey, sigPubKey, userId);
return await createUserClientChallenge(ip, userId, clientId, encPubKey);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Public key(s) already registered") {
error(409, "Public key(s) already used");
}
throw e;
}
}
};
export const verifyUserClient = async (
userId: number,
ip: string,
challengeId: number,
answerSig: string,
) => {
const challenge = await consumeUserClientChallenge(challengeId, userId, 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");
}
await setUserClientStateToPending(userId, client.id);
};
export const getUserClientStatus = async (userId: number, clientId: number) => {
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
}
return {
state: userClient.state as "pending" | "active",
isInitialMekNeeded: await isInitialMekNeeded(userId),
};
};

View File

@@ -0,0 +1,98 @@
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 { subDirectories, files } = await unregisterDirectory(userId, directoryId);
return {
directories: [...subDirectories.map(({ id }) => id), directoryId],
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 {
const { id } = await registerDirectory(params);
return { id };
} 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

@@ -0,0 +1,31 @@
import { error } from "@sveltejs/kit";
import { IntegrityError } from "$lib/server/db/error";
import { registerInitialHsk, getAllValidHsks } from "$lib/server/db/hsk";
export const getHskList = async (userId: number) => {
const hsks = await getAllValidHsks(userId);
return {
encHsks: hsks.map(({ version, state, mekVersion, encHsk }) => ({
version,
state,
mekVersion,
encHsk,
})),
};
};
export const registerInitialActiveHsk = async (
userId: number,
createdBy: number,
mekVersion: number,
encHsk: string,
) => {
try {
await registerInitialHsk(userId, createdBy, mekVersion, encHsk);
} catch (e) {
if (e instanceof IntegrityError && e.message === "HSK already registered") {
error(409, "Initial HSK already registered");
}
throw e;
}
};

View File

@@ -0,0 +1,38 @@
import { error } from "@sveltejs/kit";
import { setUserClientStateToActive } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { registerInitialMek, getAllValidClientMeks } from "$lib/server/db/mek";
import { verifyClientEncMekSig } from "$lib/server/modules/mek";
export const getClientMekList = async (userId: number, clientId: number) => {
const clientMeks = await getAllValidClientMeks(userId, clientId);
return {
encMeks: clientMeks.map(({ version, state, encMek, encMekSig }) => ({
version,
state,
encMek,
encMekSig,
})),
};
};
export const registerInitialActiveMek = async (
userId: number,
createdBy: number,
encMek: string,
encMekSig: string,
) => {
if (!(await verifyClientEncMekSig(userId, createdBy, 1, encMek, encMekSig))) {
error(400, "Invalid signature");
}
try {
await registerInitialMek(userId, createdBy, encMek, encMekSig);
await setUserClientStateToActive(userId, createdBy);
} catch (e) {
if (e instanceof IntegrityError && e.message === "MEK already registered") {
error(409, "Initial MEK already registered");
}
throw e;
}
};

View File

@@ -0,0 +1,15 @@
import { error } from "@sveltejs/kit";
import { getUser, setUserNickname } from "$lib/server/db/user";
export const getUserInformation = async (userId: number) => {
const user = await getUser(userId);
if (!user) {
error(500, "Invalid session id");
}
return { email: user.email, nickname: user.nickname };
};
export const changeNickname = async (userId: number, nickname: string) => {
await setUserNickname(userId, nickname);
};

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,3 +1,4 @@
import { callGetApi } from "$lib/hooks";
import { getAllFileInfos } from "$lib/indexedDB/filesystem";
import { decryptData } from "$lib/modules/crypto";
import {
@@ -10,8 +11,11 @@ import {
downloadFile,
} from "$lib/modules/file";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
import { trpc } from "$trpc/client";
import type {
FileThumbnailInfoResponse,
FileThumbnailUploadRequest,
FileListResponse,
} from "$lib/server/schemas";
export const requestFileDownload = async (
fileId: number,
@@ -44,20 +48,16 @@ export const requestFileThumbnailUpload = async (
return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
};
export const requestFileThumbnailDownload = async (fileId: number, dataKey?: CryptoKey) => {
export const requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => {
const cache = await getFileThumbnailCache(fileId);
if (cache || !dataKey) return cache;
if (cache) return cache;
let thumbnailInfo;
try {
thumbnailInfo = await trpc().file.thumbnail.query({ id: fileId });
} catch {
// TODO: Error Handling
return null;
}
const { contentIv: thumbnailEncryptedIv } = thumbnailInfo;
let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
if (!res.ok) return null;
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
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();
@@ -68,14 +68,10 @@ export const requestFileThumbnailDownload = async (fileId: number, dataKey?: Cry
};
export const requestDeletedFilesCleanup = async () => {
let liveFiles;
try {
liveFiles = await trpc().file.list.query();
} catch {
// TODO: Error Handling
return;
}
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 { callGetApi, callPostApi } from "$lib/hooks";
import { storeMasterKeys } from "$lib/indexedDB";
import {
encodeToBase64,
@@ -8,9 +9,16 @@ import {
signMasterKeyWrapped,
verifyMasterKeyWrapped,
} from "$lib/modules/crypto";
import type {
ClientRegisterRequest,
ClientRegisterResponse,
ClientRegisterVerifyRequest,
InitialHmacSecretRegisterRequest,
MasterKeyListResponse,
InitialMasterKeyRegisterRequest,
} from "$lib/server/schemas";
import { requestSessionUpgrade } from "$lib/services/auth";
import { masterKeyStore, type ClientKeys } from "$lib/stores";
import { trpc, isTRPCClientError } from "$trpc/client";
export const requestClientRegistration = async (
encryptKeyBase64: string,
@@ -18,22 +26,21 @@ export const requestClientRegistration = async (
verifyKeyBase64: string,
signKey: CryptoKey,
) => {
try {
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({
id,
answerSig: encodeToBase64(answerSig),
});
return true;
} catch {
// TODO: Error Handling
return false;
}
let res = await callPostApi<ClientRegisterRequest>("/api/client/register", {
encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64,
});
if (!res.ok) return false;
const { id, challenge }: ClientRegisterResponse = await res.json();
const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessageRSA(answer, signKey);
res = await callPostApi<ClientRegisterVerifyRequest>("/api/client/register/verify", {
id,
answerSig: encodeToBase64(answerSig),
});
return res.ok;
};
export const requestClientRegistrationAndSessionUpgrade = async (
@@ -66,14 +73,10 @@ export const requestClientRegistrationAndSessionUpgrade = async (
};
export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => {
let masterKeysWrapped;
try {
masterKeysWrapped = await trpc().mek.list.query();
} catch {
// TODO: Error Handling
return false;
}
const res = await callGetApi("/api/mek/list");
if (!res.ok) return false;
const { meks: masterKeysWrapped }: MasterKeyListResponse = await res.json();
const masterKeys = await Promise.all(
masterKeysWrapped.map(
async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => {
@@ -105,27 +108,17 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
hmacSecretWrapped: string,
signKey: CryptoKey,
) => {
try {
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")) {
return true;
}
// TODO: Error Handling
return false;
let res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", {
mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
});
if (!res.ok) {
return res.status === 403 || res.status === 409;
}
try {
await trpc().hsk.registerInitial.mutate({
mekVersion: 1,
hsk: hmacSecretWrapped,
});
return true;
} catch {
// TODO: Error Handling
return false;
}
res = await callPostApi<InitialHmacSecretRegisterRequest>("/api/hsk/register/initial", {
mekVersion: 1,
hsk: hmacSecretWrapped,
});
return res.ok;
};

View File

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

View File

@@ -1,57 +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) => {
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);
};

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

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

View File

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

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import { isFileDownloading, type FileDownloadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils";
interface Props {
status?: Writable<FileDownloadStatus>;

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,8 +1,8 @@
<script lang="ts">
import { get, type Writable } from "svelte/store";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { getFileInfo } from "$lib/modules/filesystem2";
import { formatNetworkSpeed } from "$lib/modules/util";
import { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils";
import IconCloud from "~icons/material-symbols/cloud";
import IconCloudDownload from "~icons/material-symbols/cloud-download";
@@ -17,14 +17,10 @@
let { status }: Props = $props();
let fileInfo: Writable<FileInfo | null> | undefined = $state();
$effect(() => {
fileInfo = getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!);
});
let fileInfo = $derived(getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!));
</script>
{#if $fileInfo}
{#if $fileInfo.status === "success"}
<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"}
@@ -42,8 +38,8 @@
{/if}
</div>
<div class="flex-grow overflow-hidden">
<p title={$fileInfo.name} class="truncate font-medium">
{$fileInfo.name}
<p title={$fileInfo.data.name} class="truncate font-medium">
{$fileInfo.data.name}
</p>
<p class="text-xs text-gray-800">
{#if $status.status === "download-pending"}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import type { FileUploadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils";
import IconPending from "~icons/material-symbols/pending";
import IconLockClock from "~icons/material-symbols/lock-clock";

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,26 +0,0 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import { Gallery } from "$lib/components/organisms";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
let { data } = $props();
let files: Writable<FileInfo | null>[] = $state([]);
$effect(() => {
files = data.files.map((file) => getFileInfo(file, $masterKeyStore?.get(1)?.key!));
});
</script>
<svelte:head>
<title>사진 및 동영상</title>
</svelte:head>
<TopBar title="사진 및 동영상" />
<FullscreenDiv>
<Gallery {files} onFileClick={({ id }) => goto(`/file/${id}`)} />
</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,18 +1,17 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import type { FileCacheIndex } from "$lib/indexedDB";
import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { getFileInfo, type FileInfoStore } from "$lib/modules/filesystem2";
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;
fileInfo: Writable<FileInfo | null>;
fileInfo: FileInfoStore;
}
let fileCache: FileCache[] | undefined = $state();

View File

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

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

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

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

View File

@@ -2,7 +2,7 @@ import { limitFunction } from "p-limit";
import { get, writable, type Writable } from "svelte/store";
import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file";
import type { FileInfo } from "$lib/modules/filesystem";
import type { FileInfo, FileInfoStore } from "$lib/modules/filesystem2";
import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail";
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
@@ -17,7 +17,7 @@ export type GenerationStatus =
interface File {
id: number;
info: Writable<FileInfo | null>;
info: FileInfoStore;
status?: Writable<GenerationStatus>;
}

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

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,7 +1,8 @@
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";
@@ -19,26 +20,15 @@ export const useContext = () => {
export const requestCategoryRename = async (category: SelectedCategory, newName: string) => {
const newNameEncrypted = await encryptString(newName, category.dataKey);
try {
await trpc().category.rename.mutate({
id: category.id,
dekVersion: category.dataKeyVersion,
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,11 +1,23 @@
<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 DirectoryInfo } from "$lib/modules/filesystem";
import {
storeFileCache,
deleteFileCache,
storeFileThumbnailCache,
deleteFileThumbnailCache,
} from "$lib/modules/file";
import {
getDirectoryInfo,
useDirectoryCreation,
useDirectoryRename,
useDirectoryDeletion,
useFileUpload,
useFileRename,
useFileDeletion,
} from "$lib/modules/filesystem2";
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries";
@@ -16,21 +28,23 @@
import EntryMenuBottomSheet from "./EntryMenuBottomSheet.svelte";
import EntryRenameModal from "./EntryRenameModal.svelte";
import UploadStatusCard from "./UploadStatusCard.svelte";
import {
createContext,
requestHmacSecretDownload,
requestDirectoryCreation,
requestFileUpload,
requestEntryRename,
requestEntryDeletion,
} from "./service.svelte";
import { createContext, requestHmacSecretDownload } from "./service.svelte";
import IconAdd from "~icons/material-symbols/add";
let { data } = $props();
let context = createContext();
let info: Writable<DirectoryInfo | null> | undefined = $state();
let info = $derived(getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!));
let requestDirectoryCreation = $derived(useDirectoryCreation(data.id, $masterKeyStore?.get(1)!));
let requestDirectoryRename = useDirectoryRename();
let requestDirectoryDeletion = $derived(useDirectoryDeletion(data.id));
let requestFileUpload = $derived(
useFileUpload(data.id, $masterKeyStore?.get(1)!, $hmacSecretStore?.get(1)!),
);
let requestFileRename = $derived(useFileRename());
let requestFileDeletion = $derived(useFileDeletion(data.id));
let fileInput: HTMLInputElement | undefined = $state();
let duplicatedFile: File | undefined = $state();
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
@@ -43,29 +57,29 @@
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;
for (const file of files) {
requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => {
return new Promise((resolve) => {
duplicatedFile = file;
resolveForDuplicateFileModal = resolve;
isDuplicateFileModalOpen = true;
});
})
.then((res) => {
if (!res) return;
// TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
$requestFileUpload
.mutateAsync({
file,
onDuplicate: () => {
return new Promise((resolve) => {
duplicatedFile = file;
resolveForDuplicateFileModal = resolve;
isDuplicateFileModalOpen = true;
});
},
})
.catch((e: Error) => {
// TODO: FIXME
console.error(e);
.then((res) => {
if (res) {
storeFileCache(res.fileId, res.fileBuffer); // Intended
if (res.thumbnailBuffer) {
storeFileThumbnailCache(res.fileId, res.thumbnailBuffer); // Intended
}
}
});
}
@@ -77,10 +91,6 @@
throw new Error("Failed to download hmac secrets");
}
});
$effect(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
</script>
<svelte:head>
@@ -90,30 +100,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.data?.name} class="flex-shrink-0" />
{/if}
{#if $info}
<div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "pt-4"]}>
{#if $info.status === "success"}
<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}
{#key $info.data.id}
<DirectoryEntries
info={$info}
info={$info.data}
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>
@@ -141,11 +144,8 @@
<DirectoryCreateModal
bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => {
if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
$requestDirectoryCreation.mutate({ name });
return true; // TODO
}}
/>
<DuplicateFileModal
@@ -175,20 +175,45 @@
<EntryRenameModal
bind:isOpen={isEntryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (await requestEntryRename(context.selectedEntry!, newName)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
if (context.selectedEntry!.type === "directory") {
$requestDirectoryRename.mutate({
id: context.selectedEntry!.id,
dataKey: context.selectedEntry!.dataKey,
dataKeyVersion: context.selectedEntry!.dataKeyVersion,
newName,
});
return true; // TODO
} else {
$requestFileRename.mutate({
id: context.selectedEntry!.id,
dataKey: context.selectedEntry!.dataKey,
dataKeyVersion: context.selectedEntry!.dataKeyVersion,
newName,
});
return true; // TODO
}
return false;
}}
/>
<EntryDeleteModal
bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => {
if (await requestEntryDeletion(context.selectedEntry!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
if (context.selectedEntry!.type === "directory") {
const res = await $requestDirectoryDeletion.mutateAsync({ id: context.selectedEntry!.id });
if (!res) return false;
await Promise.all(
res.deletedFiles.flatMap((fileId) => [
deleteFileCache(fileId),
deleteFileThumbnailCache(fileId),
]),
);
return true; // TODO
} else {
await $requestFileDeletion.mutateAsync({ id: context.selectedEntry!.id });
await Promise.all([
deleteFileCache(context.selectedEntry!.id),
deleteFileThumbnailCache(context.selectedEntry!.id),
]);
return true; // TODO
}
return false;
}}
/>

View File

@@ -1,21 +1,19 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { ActionEntryButton, RowVirtualizer } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import { derived } from "svelte/store";
import {
getDirectoryInfo,
getFileInfo,
type DirectoryInfo,
type SubDirectoryInfo,
type FileInfo,
} from "$lib/modules/filesystem";
} from "$lib/modules/filesystem2";
import { SortBy, sortEntries } from "$lib/modules/util";
import {
fileUploadStatusStore,
isFileUploading,
masterKeyStore,
type FileUploadStatus,
} from "$lib/stores";
import { SortBy, sortEntries } from "$lib/utils";
import File from "./File.svelte";
import SubDirectory from "./SubDirectory.svelte";
import UploadingFile from "./UploadingFile.svelte";
@@ -25,125 +23,97 @@
info: DirectoryInfo;
onEntryClick: (entry: SelectedEntry) => void;
onEntryMenuClick: (entry: SelectedEntry) => void;
onParentClick?: () => void;
showParentEntry?: boolean;
sortBy?: SortBy;
}
let {
info,
onEntryClick,
onEntryMenuClick,
onParentClick,
showParentEntry = false,
sortBy = SortBy.NAME_ASC,
}: Props = $props();
let { info, onEntryClick, onEntryMenuClick, sortBy = SortBy.NAME_ASC }: Props = $props();
interface DirectoryEntry {
name?: string;
info: Writable<DirectoryInfo | null>;
info: SubDirectoryInfo;
}
type FileEntry =
| {
type: "file";
name?: string;
info: Writable<FileInfo | null>;
info: FileInfo;
}
| {
type: "uploading-file";
name: string;
info: Writable<FileUploadStatus>;
info: FileUploadStatus;
};
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();
}),
let subDirectories = $derived(
derived(
info.subDirectoryIds.map((id) => getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!)),
(infos) => {
const subDirectories = infos
.filter(($info) => $info.status === "success")
.map(
($info) =>
({
name: $info.data.name,
info: $info.data as SubDirectoryInfo,
}) satisfies DirectoryEntry,
);
sortEntries(subDirectories, sortBy);
return subDirectories;
},
),
);
let files = $derived(
derived(
info.fileIds.map((id) => getFileInfo(id, $masterKeyStore?.get(1)?.key!)),
(infos) =>
infos
.filter(($info) => $info.status === "success")
.map(
($info) =>
({
type: "file",
name: $info.data.name,
info: $info.data,
}) satisfies FileEntry,
),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
),
);
let uploadingFiles = $derived(
derived($fileUploadStatusStore, (statuses) =>
statuses
.filter(({ parentId, status }) => parentId === info.id && isFileUploading(status))
.map(
($status) =>
({
type: "uploading-file",
name: $status.name,
info: $status,
}) satisfies FileEntry,
),
),
);
let everyFiles = $derived(
derived([files, uploadingFiles], ([$files, $uploadingFiles]) => {
const allFiles = [...$files, ...$uploadingFiles];
sortEntries(allFiles, sortBy);
return allFiles;
}),
);
</script>
{#if subDirectories.length + files.length > 0 || showParentEntry}
{#if $subDirectories.length + $everyFiles.length > 0}
<div class="space-y-1 pb-[4.5rem]">
{#if showParentEntry}
<ActionEntryButton class="h-14" onclick={onParentClick}>
<DirectoryEntryLabel type="parent-directory" name=".." />
</ActionEntryButton>
{/if}
{#each subDirectories as { info }}
{#each $subDirectories as { info }}
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{/each}
{#if files.length > 0}
<RowVirtualizer
count={files.length}
itemHeight={(index) => 56 + (index + 1 < files.length ? 4 : 0)}
>
{#snippet item(index)}
{@const file = files[index]!}
<div class={index + 1 < files.length ? "pb-1" : ""}>
{#if file.type === "file"}
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else}
<UploadingFile status={file.info} />
{/if}
</div>
{/snippet}
</RowVirtualizer>
{/if}
{#each $everyFiles as file}
{#if file.type === "file"}
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else}
<UploadingFile status={file.info} />
{/if}
{/each}
</div>
{:else}
<div class="flex flex-grow items-center justify-center">

View File

@@ -1,16 +1,15 @@
<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 { FileInfo } from "$lib/modules/filesystem2";
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: Writable<FileInfo | null>;
info: FileInfo;
onclick: (selectedEntry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void;
}
@@ -20,22 +19,22 @@
let thumbnail: string | undefined = $state();
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info!;
const { id, dataKey, dataKeyVersion, name } = info;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
};
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = $info!;
const { id, dataKey, dataKeyVersion, name } = info;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
if (info.dataKey) {
requestFileThumbnailDownload(info.id, info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
@@ -49,18 +48,16 @@
});
</script>
{#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}
<ActionEntryButton
class="h-14"
onclick={openFile}
actionButtonIcon={IconMoreVert}
onActionButtonClick={openMenu}
>
<DirectoryEntryLabel
type="file"
{thumbnail}
name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
/>
</ActionEntryButton>

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { ActionModal } from "$lib/components/molecules";
import { truncateString } from "$lib/utils";
import { truncateString } from "$lib/modules/util";
interface Props {
file: File | undefined;

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,4 +1,5 @@
import { getContext, setContext } from "svelte";
import { callGetApi, callPostApi } from "$lib/hooks";
import { storeHmacSecrets } from "$lib/indexedDB";
import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
import {
@@ -8,8 +9,14 @@ import {
deleteFileThumbnailCache,
uploadFile,
} from "$lib/modules/file";
import type {
DirectoryRenameRequest,
DirectoryCreateRequest,
FileRenameRequest,
HmacSecretListResponse,
DirectoryDeleteResponse,
} from "$lib/server/schemas";
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
import { trpc } from "$trpc/client";
export interface SelectedEntry {
type: "directory" | "file";
@@ -33,14 +40,10 @@ export const useContext = () => {
export const requestHmacSecretDownload = async (masterKey: CryptoKey) => {
// TODO: MEK rotation
let hmacSecretsWrapped;
try {
hmacSecretsWrapped = await trpc().hsk.list.query();
} catch {
// TODO: Error Handling
return false;
}
const res = await callGetApi("/api/hsk/list");
if (!res.ok) return false;
const { hsks: hmacSecretsWrapped }: HmacSecretListResponse = await res.json();
const hmacSecrets = await Promise.all(
hmacSecretsWrapped.map(async ({ version, state, hsk: hmacSecretWrapped }) => {
const { hmacSecret } = await unwrapHmacSecret(hmacSecretWrapped, masterKey);
@@ -62,20 +65,15 @@ export const requestDirectoryCreation = async (
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
try {
await trpc().directory.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<DirectoryCreateRequest>("/api/directory/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 requestFileUpload = async (
@@ -99,46 +97,35 @@ export const requestFileUpload = async (
export const requestEntryRename = async (entry: SelectedEntry, newName: string) => {
const newNameEncrypted = await encryptString(newName, entry.dataKey);
try {
if (entry.type === "directory") {
await trpc().directory.rename.mutate({
id: entry.id,
dekVersion: entry.dataKeyVersion,
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
} else {
await trpc().file.rename.mutate({
id: entry.id,
dekVersion: entry.dataKeyVersion,
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
}
return true;
} catch {
// TODO: Error Handling
return false;
let res;
if (entry.type === "directory") {
res = await callPostApi<DirectoryRenameRequest>(`/api/directory/${entry.id}/rename`, {
dekVersion: entry.dataKeyVersion.toISOString(),
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
} else {
res = await callPostApi<FileRenameRequest>(`/api/file/${entry.id}/rename`, {
dekVersion: entry.dataKeyVersion.toISOString(),
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
}
return res.ok;
};
export const requestEntryDeletion = async (entry: SelectedEntry) => {
try {
if (entry.type === "directory") {
const { deletedFiles } = await trpc().directory.delete.mutate({ id: entry.id });
await Promise.all(
deletedFiles.flatMap((fileId) => [
deleteFileCache(fileId),
deleteFileThumbnailCache(fileId),
]),
);
} else {
await trpc().file.delete.mutate({ id: entry.id });
await Promise.all([deleteFileCache(entry.id), deleteFileThumbnailCache(entry.id)]);
}
const res = await callPostApi(`/api/${entry.type}/${entry.id}/delete`);
if (!res.ok) return false;
if (entry.type === "directory") {
const { deletedFiles }: DirectoryDeleteResponse = await res.json();
await Promise.all(
deletedFiles.flatMap((fileId) => [deleteFileCache(fileId), deleteFileThumbnailCache(fileId)]),
);
return true;
} else {
await Promise.all([deleteFileCache(entry.id), deleteFileThumbnailCache(entry.id)]);
return true;
} catch {
// TODO: Error Handling
return false;
}
};

View File

@@ -1,36 +1,3 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { EntryButton, FileThumbnailButton } from "$lib/components/atoms";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { requestFreshMediaFilesRetrieval } from "./service";
let mediaFiles: Writable<FileInfo | null>[] = $state([]);
$effect(() => {
requestFreshMediaFilesRetrieval().then((files) => {
mediaFiles = files.map(({ id }) => getFileInfo(id, $masterKeyStore?.get(1)?.key!));
});
});
</script>
<svelte:head>
<title></title>
</svelte:head>
<div class="min-h-full space-y-4 bg-gray-100 px-4 pb-[5.5em] pt-4">
<p class="px-2 text-2xl font-bold text-gray-800">ArkVault</p>
<div class="rounded-xl bg-white p-2">
<EntryButton onclick={() => goto("/gallery")} class="w-full">
<p class="text-left font-semibold">사진 및 동영상</p>
</EntryButton>
{#if mediaFiles.length > 0}
<div class="grid grid-cols-4 gap-2 p-2">
{#each mediaFiles as file}
<FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} />
{/each}
</div>
{/if}
</div>
<div class="flex h-full items-center justify-center p-4">
<p class="text-gray-500">아직 개발 중이에요.</p>
</div>

View File

@@ -1,14 +0,0 @@
import { getAllFileInfos } from "$lib/indexedDB";
export const requestFreshMediaFilesRetrieval = async (limit = 8) => {
const files = await getAllFileInfos();
files.sort(
(a, b) =>
(b.createdAt ?? b.lastModifiedAt).getTime() - (a.createdAt ?? a.lastModifiedAt).getTime(),
);
return files
.filter(
({ contentType }) => contentType.startsWith("image/") || contentType.startsWith("video/"),
)
.slice(0, limit);
};

View File

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

View File

@@ -0,0 +1,14 @@
import { error } from "@sveltejs/kit";
import { callGetApi } from "$lib/hooks";
import type { UserInfoResponse } from "$lib/server/schemas";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const res = await callGetApi("/api/user", { fetch });
if (!res.ok) {
error(500, "Internal server error");
}
const { nickname }: UserInfoResponse = await res.json();
return { nickname };
};

View File

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

View File

@@ -0,0 +1,16 @@
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { passwordChangeRequest } from "$lib/server/schemas";
import { changePassword } from "$lib/server/services/auth";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { sessionId, userId } = await authorize(locals, "any");
const zodRes = passwordChangeRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { oldPassword, newPassword } = zodRes.data;
await changePassword(userId, sessionId, oldPassword, newPassword);
return text("Password changed", { headers: { "Content-Type": "text/plain" } });
};

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