7 Commits

83 changed files with 2256 additions and 2749 deletions

View File

@@ -2,11 +2,7 @@
FROM node:22-alpine AS base FROM node:22-alpine AS base
WORKDIR /app WORKDIR /app
RUN apk add --no-cache bash curl && \ RUN npm install -g pnpm@10
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 . COPY pnpm-lock.yaml .
# Build Stage # Build Stage
@@ -29,4 +25,4 @@ COPY --from=build /app/build ./build
EXPOSE 3000 EXPOSE 3000
ENV BODY_SIZE_LIMIT=Infinity ENV BODY_SIZE_LIMIT=Infinity
CMD ["bash", "-c", "wait-for-it ${DATABASE_HOST:-localhost}:${DATABASE_PORT:-5432} -- node ./build/index.js"] CMD ["node", "./build/index.js"]

View File

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

View File

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

View File

@@ -16,55 +16,55 @@
"db:migrate": "kysely migrate" "db:migrate": "kysely migrate"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.3.1", "@eslint/compat": "^1.4.1",
"@iconify-json/material-symbols": "^1.2.29", "@iconify-json/material-symbols": "^1.2.44",
"@sveltejs/adapter-node": "^5.2.13", "@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.22.5", "@sveltejs/kit": "^2.48.4",
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tanstack/eslint-plugin-query": "^5.81.2", "@trpc/client": "^11.7.1",
"@tanstack/svelte-query": "^5.83.0",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34", "@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.8", "@types/node-schedule": "^2.1.8",
"@types/pg": "^8.15.4", "@types/pg": "^8.15.6",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"axios": "^1.10.0", "axios": "^1.13.1",
"dexie": "^4.0.11", "dexie": "^4.2.1",
"eslint": "^9.30.1", "eslint": "^9.39.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.10.1", "eslint-plugin-svelte": "^3.13.0",
"eslint-plugin-tailwindcss": "^3.18.0", "eslint-plugin-tailwindcss": "^3.18.2",
"exifreader": "^4.31.1", "exifreader": "^4.32.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"globals": "^16.3.0", "globals": "^16.5.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"kysely-ctl": "^0.13.1", "kysely-ctl": "^0.19.0",
"lru-cache": "^11.1.0", "lru-cache": "^11.2.2",
"mime": "^4.0.7", "mime": "^4.1.0",
"p-limit": "^6.2.0", "p-limit": "^7.2.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.7.1",
"svelte": "^5.35.6", "svelte": "^5.43.2",
"svelte-check": "^4.2.2", "svelte-check": "^4.3.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.18",
"typescript": "^5.8.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.36.0", "typescript-eslint": "^8.46.2",
"unplugin-icons": "^22.1.0", "unplugin-icons": "^22.5.0",
"vite": "^5.4.19" "vite": "^7.1.12"
}, },
"dependencies": { "dependencies": {
"@fastify/busboy": "^3.1.1", "@fastify/busboy": "^3.2.0",
"argon2": "^0.43.0", "@trpc/server": "^11.7.1",
"kysely": "^0.28.2", "argon2": "^0.44.0",
"kysely": "^0.28.8",
"ms": "^2.1.3", "ms": "^2.1.3",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"uuid": "^11.1.0", "uuid": "^13.0.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"engines": { "engines": {
"node": "^22.0.0", "node": "^22.0.0",
"pnpm": "^9.0.0" "pnpm": "^10.0.0"
} }
} }

1824
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,360 @@
import { get, writable, type Writable } from "svelte/store";
import { callGetApi } from "$lib/hooks";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
type DirectoryId,
type CategoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import type {
CategoryInfoResponse,
CategoryFileListResponse,
DirectoryInfoResponse,
FileInfoResponse,
} from "$lib/server/schemas";
export type DirectoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export interface FileInfo {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
export type CategoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subCategoryIds: number[];
files?: undefined;
isFileRecursive?: undefined;
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subCategoryIds: number[];
files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
};
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const categoryInfoStore = new Map<CategoryId, Writable<CategoryInfo | null>>();
const fetchDirectoryInfoFromIndexedDB = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
) => {
if (get(info)) return;
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
const subDirectoryIds = subDirectories.map(({ id }) => id);
const fileIds = files.map(({ id }) => id);
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
if (!directory) return;
info.set({ id, name: directory.name, subDirectoryIds, fileIds });
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
const res = await callGetApi(`/api/directory/${id}`);
if (res.status === 404) {
info.set(null);
await deleteDirectoryInfo(id as number);
return;
} else if (!res.ok) {
throw new Error("Failed to fetch directory information");
}
const {
metadata,
subDirectories: subDirectoryIds,
files: fileIds,
}: DirectoryInfoResponse = await res.json();
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
info.set({
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
});
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
}
};
const fetchDirectoryInfo = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
await fetchDirectoryInfoFromIndexedDB(id, info);
await fetchDirectoryInfoFromServer(id, info, masterKey);
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = directoryInfoStore.get(id);
if (!info) {
info = writable(null);
directoryInfoStore.set(id, info);
}
fetchDirectoryInfo(id, info, masterKey); // Intended
return info;
};
const fetchFileInfoFromIndexedDB = async (id: number, info: Writable<FileInfo | null>) => {
if (get(info)) return;
const file = await getFileInfoFromIndexedDB(id);
if (!file) return;
info.set(file);
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
const fetchFileInfoFromServer = async (
id: number,
info: Writable<FileInfo | null>,
masterKey: CryptoKey,
) => {
const res = await callGetApi(`/api/file/${id}`);
if (res.status === 404) {
info.set(null);
await deleteFileInfo(id);
return;
} else if (!res.ok) {
throw new Error("Failed to fetch file information");
}
const metadata: FileInfoResponse = await res.json();
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
const createdAt =
metadata.createdAt && metadata.createdAtIv
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
: undefined;
const lastModifiedAt = await decryptDate(
metadata.lastModifiedAt,
metadata.lastModifiedAtIv,
dataKey,
);
info.set({
id,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
};
const fetchFileInfo = async (id: number, info: Writable<FileInfo | null>, masterKey: CryptoKey) => {
await fetchFileInfoFromIndexedDB(id, info);
await fetchFileInfoFromServer(id, info, masterKey);
};
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = fileInfoStore.get(fileId);
if (!info) {
info = writable(null);
fileInfoStore.set(fileId, info);
}
fetchFileInfo(fileId, info, masterKey); // Intended
return info;
};
const fetchCategoryInfoFromIndexedDB = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
) => {
if (get(info)) return;
const [category, subCategories] = await Promise.all([
id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined,
getCategoryInfosFromIndexedDB(id),
]);
const subCategoryIds = subCategories.map(({ id }) => id);
if (id === "root") {
info.set({ id, subCategoryIds });
} else {
if (!category) return;
info.set({
id,
name: category.name,
subCategoryIds,
files: category.files,
isFileRecursive: category.isFileRecursive,
});
}
};
const fetchCategoryInfoFromServer = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
masterKey: CryptoKey,
) => {
let res = await callGetApi(`/api/category/${id}`);
if (res.status === 404) {
info.set(null);
await deleteCategoryInfo(id as number);
return;
} else if (!res.ok) {
throw new Error("Failed to fetch category information");
}
const { metadata, subCategories }: CategoryInfoResponse = await res.json();
if (id === "root") {
info.set({ id, subCategoryIds: subCategories });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
res = await callGetApi(`/api/category/${id}/file/list?recurse=true`);
if (!res.ok) {
throw new Error("Failed to fetch category files");
}
const { files }: CategoryFileListResponse = await res.json();
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
let isFileRecursive: boolean | undefined = undefined;
info.update((value) => {
const newValue = {
isFileRecursive: false,
...value,
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subCategoryIds: subCategories,
files: filesMapped,
};
isFileRecursive = newValue.isFileRecursive;
return newValue;
});
await storeCategoryInfo({
id,
parentId: metadata!.parent,
name,
files: filesMapped,
isFileRecursive: isFileRecursive!,
});
}
};
const fetchCategoryInfo = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
masterKey: CryptoKey,
) => {
await fetchCategoryInfoFromIndexedDB(id, info);
await fetchCategoryInfoFromServer(id, info, masterKey);
};
export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = categoryInfoStore.get(categoryId);
if (!info) {
info = writable(null);
categoryInfoStore.set(categoryId, info);
}
fetchCategoryInfo(categoryId, info, masterKey); // Intended
return info;
};
export const updateCategoryInfo = async (
categoryId: number,
changes: { isFileRecursive?: boolean },
) => {
await updateCategoryInfoInIndexedDB(categoryId, changes);
categoryInfoStore.get(categoryId)?.update((value) => {
if (!value) return value;
if (changes.isFileRecursive !== undefined) {
value.isFileRecursive = changes.isFileRecursive;
}
return value;
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,22 +98,6 @@ 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) => { export const getUserClient = async (userId: number, clientId: number) => {
const userClient = await db const userClient = await db
.selectFrom("user_client") .selectFrom("user_client")

View File

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

View File

@@ -0,0 +1,10 @@
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,19 +60,6 @@ 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) => { export const getAllValidClientMeks = async (userId: number, clientId: number) => {
const clientMeks = await db const clientMeks = await db
.selectFrom("client_master_encryption_key") .selectFrom("client_master_encryption_key")

View File

@@ -4,7 +4,7 @@ import { authenticate, AuthenticationError } from "$lib/server/modules/auth";
export const authenticateMiddleware: Handle = async ({ event, resolve }) => { export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
const { pathname, search } = event.url; const { pathname, search } = event.url;
if (pathname === "/api/auth/login") { if (pathname === "/api/auth/login" || pathname.startsWith("/api/trpc")) {
return await resolve(event); return await resolve(event);
} }

View File

@@ -11,10 +11,17 @@ interface Session {
clientId?: number; clientId?: number;
} }
interface ClientSession extends Session { export interface ClientSession extends Session {
clientId: number; clientId: number;
} }
export type SessionPermission =
| "any"
| "notClient"
| "anyClient"
| "pendingClient"
| "activeClient";
export class AuthenticationError extends Error { export class AuthenticationError extends Error {
constructor( constructor(
public status: 400 | 401, public status: 400 | 401,
@@ -25,6 +32,16 @@ export class AuthenticationError extends Error {
} }
} }
export class AuthorizationError extends Error {
constructor(
public status: 403 | 500,
message: string,
) {
super(message);
this.name = "AuthorizationError";
}
}
export const startSession = async (userId: number, ip: string, userAgent: string) => { export const startSession = async (userId: number, ip: string, userAgent: string) => {
const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret); const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret);
await createSession(userId, sessionId, ip, userAgent); await createSession(userId, sessionId, ip, userAgent);
@@ -52,34 +69,12 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
} }
}; };
export async function authorize(locals: App.Locals, requiredPermission: "any"): Promise<Session>; export const authorizeInternal = async (
export async function authorize(
locals: App.Locals, locals: App.Locals,
requiredPermission: "notClient", requiredPermission: SessionPermission,
): Promise<Session>; ): 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) { if (!locals.session) {
error(500, "Unauthenticated"); throw new AuthorizationError(500, "Unauthenticated");
} }
const { id: sessionId, userId, clientId } = locals.session; const { id: sessionId, userId, clientId } = locals.session;
@@ -89,39 +84,63 @@ export async function authorize(
break; break;
case "notClient": case "notClient":
if (clientId) { if (clientId) {
error(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
break; break;
case "anyClient": case "anyClient":
if (!clientId) { if (!clientId) {
error(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
break; break;
case "pendingClient": { case "pendingClient": {
if (!clientId) { if (!clientId) {
error(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
const userClient = await getUserClient(userId, clientId); const userClient = await getUserClient(userId, clientId);
if (!userClient) { if (!userClient) {
error(500, "Invalid session id"); throw new AuthorizationError(500, "Invalid session id");
} else if (userClient.state !== "pending") { } else if (userClient.state !== "pending") {
error(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
break; break;
} }
case "activeClient": { case "activeClient": {
if (!clientId) { if (!clientId) {
error(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
const userClient = await getUserClient(userId, clientId); const userClient = await getUserClient(userId, clientId);
if (!userClient) { if (!userClient) {
error(500, "Invalid session id"); throw new AuthorizationError(500, "Invalid session id");
} else if (userClient.state !== "active") { } else if (userClient.state !== "active") {
error(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
break; break;
} }
} }
return { sessionId, userId, clientId }; 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,25 +0,0 @@
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

@@ -53,8 +53,3 @@ export const categoryCreateRequest = z.object({
nameIv: z.string().base64().nonempty(), nameIv: z.string().base64().nonempty(),
}); });
export type CategoryCreateRequest = z.input<typeof categoryCreateRequest>; 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

@@ -1,36 +0,0 @@
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

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

@@ -1,19 +0,0 @@
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,8 +1,4 @@
export * from "./auth"; export * from "./auth";
export * from "./category"; export * from "./category";
export * from "./client";
export * from "./directory"; export * from "./directory";
export * from "./file"; export * from "./file";
export * from "./hsk";
export * from "./mek";
export * from "./user";

View File

@@ -1,19 +0,0 @@
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

@@ -1,12 +0,0 @@
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

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

View File

@@ -1,116 +0,0 @@
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

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

View File

@@ -1,31 +0,0 @@
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

@@ -1,38 +0,0 @@
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

@@ -1,15 +0,0 @@
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

@@ -48,9 +48,9 @@ export const requestFileThumbnailUpload = async (
return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form }); 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); const cache = await getFileThumbnailCache(fileId);
if (cache) return cache; if (cache || !dataKey) return cache;
let res = await callGetApi(`/api/file/${fileId}/thumbnail`); let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
if (!res.ok) return null; if (!res.ok) return null;

View File

@@ -1,4 +1,4 @@
import { callGetApi, callPostApi } from "$lib/hooks"; import { TRPCClientError } from "@trpc/client";
import { storeMasterKeys } from "$lib/indexedDB"; import { storeMasterKeys } from "$lib/indexedDB";
import { import {
encodeToBase64, encodeToBase64,
@@ -9,16 +9,9 @@ import {
signMasterKeyWrapped, signMasterKeyWrapped,
verifyMasterKeyWrapped, verifyMasterKeyWrapped,
} from "$lib/modules/crypto"; } from "$lib/modules/crypto";
import type {
ClientRegisterRequest,
ClientRegisterResponse,
ClientRegisterVerifyRequest,
InitialHmacSecretRegisterRequest,
MasterKeyListResponse,
InitialMasterKeyRegisterRequest,
} from "$lib/server/schemas";
import { requestSessionUpgrade } from "$lib/services/auth"; import { requestSessionUpgrade } from "$lib/services/auth";
import { masterKeyStore, type ClientKeys } from "$lib/stores"; import { masterKeyStore, type ClientKeys } from "$lib/stores";
import { useTRPC } from "$trpc/client";
export const requestClientRegistration = async ( export const requestClientRegistration = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -26,21 +19,24 @@ export const requestClientRegistration = async (
verifyKeyBase64: string, verifyKeyBase64: string,
signKey: CryptoKey, signKey: CryptoKey,
) => { ) => {
let res = await callPostApi<ClientRegisterRequest>("/api/client/register", { const trpc = useTRPC();
try {
const { id, challenge } = await trpc.client.register.mutate({
encPubKey: encryptKeyBase64, encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64, sigPubKey: verifyKeyBase64,
}); });
if (!res.ok) return false;
const { id, challenge }: ClientRegisterResponse = await res.json();
const answer = await decryptChallenge(challenge, decryptKey); const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessageRSA(answer, signKey); const answerSig = await signMessageRSA(answer, signKey);
await trpc.client.verify.mutate({
res = await callPostApi<ClientRegisterVerifyRequest>("/api/client/register/verify", {
id, id,
answerSig: encodeToBase64(answerSig), answerSig: encodeToBase64(answerSig),
}); });
return res.ok; return true;
} catch {
// TODO: Error Handling
return false;
}
}; };
export const requestClientRegistrationAndSessionUpgrade = async ( export const requestClientRegistrationAndSessionUpgrade = async (
@@ -73,10 +69,16 @@ export const requestClientRegistrationAndSessionUpgrade = async (
}; };
export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => { export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => {
const res = await callGetApi("/api/mek/list"); const trpc = useTRPC();
if (!res.ok) return false;
let masterKeysWrapped;
try {
masterKeysWrapped = await trpc.mek.list.query();
} catch {
// TODO: Error Handling
return false;
}
const { meks: masterKeysWrapped }: MasterKeyListResponse = await res.json();
const masterKeys = await Promise.all( const masterKeys = await Promise.all(
masterKeysWrapped.map( masterKeysWrapped.map(
async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => { async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => {
@@ -108,17 +110,32 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
hmacSecretWrapped: string, hmacSecretWrapped: string,
signKey: CryptoKey, signKey: CryptoKey,
) => { ) => {
let res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", { const trpc = useTRPC();
try {
await trpc.mek.registerInitial.mutate({
mek: masterKeyWrapped, mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey), mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
}); });
if (!res.ok) { } catch (e) {
return res.status === 403 || res.status === 409; if (
e instanceof TRPCClientError &&
(e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")
) {
return true;
}
// TODO: Error Handling
return false;
} }
res = await callPostApi<InitialHmacSecretRegisterRequest>("/api/hsk/register/initial", { try {
await trpc.hsk.registerInitial.mutate({
mekVersion: 1, mekVersion: 1,
hsk: hmacSecretWrapped, hsk: hmacSecretWrapped,
}); });
return res.ok; return true;
} catch {
// TODO: Error Handling
return false;
}
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { FloatingButton } from "$lib/components/atoms"; import { FloatingButton } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules"; import { TopBar } from "$lib/components/molecules";
import { import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
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 { masterKeyStore, hmacSecretStore } from "$lib/stores";
import DirectoryCreateModal from "./DirectoryCreateModal.svelte"; import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries"; import DirectoryEntries from "./DirectoryEntries";
@@ -28,23 +15,21 @@
import EntryMenuBottomSheet from "./EntryMenuBottomSheet.svelte"; import EntryMenuBottomSheet from "./EntryMenuBottomSheet.svelte";
import EntryRenameModal from "./EntryRenameModal.svelte"; import EntryRenameModal from "./EntryRenameModal.svelte";
import UploadStatusCard from "./UploadStatusCard.svelte"; import UploadStatusCard from "./UploadStatusCard.svelte";
import { createContext, requestHmacSecretDownload } from "./service.svelte"; import {
createContext,
requestHmacSecretDownload,
requestDirectoryCreation,
requestFileUpload,
requestEntryRename,
requestEntryDeletion,
} from "./service.svelte";
import IconAdd from "~icons/material-symbols/add"; import IconAdd from "~icons/material-symbols/add";
let { data } = $props(); let { data } = $props();
let context = createContext(); let context = createContext();
let info = $derived(getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!)); let info: Writable<DirectoryInfo | null> | undefined = $state();
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 fileInput: HTMLInputElement | undefined = $state();
let duplicatedFile: File | undefined = $state(); let duplicatedFile: File | undefined = $state();
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state(); let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
@@ -62,24 +47,21 @@
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
for (const file of files) { for (const file of files) {
$requestFileUpload requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => {
.mutateAsync({
file,
onDuplicate: () => {
return new Promise((resolve) => { return new Promise((resolve) => {
duplicatedFile = file; duplicatedFile = file;
resolveForDuplicateFileModal = resolve; resolveForDuplicateFileModal = resolve;
isDuplicateFileModalOpen = true; isDuplicateFileModalOpen = true;
}); });
},
}) })
.then((res) => { .then((res) => {
if (res) { if (!res) return;
storeFileCache(res.fileId, res.fileBuffer); // Intended // TODO: FIXME
if (res.thumbnailBuffer) { info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
storeFileThumbnailCache(res.fileId, res.thumbnailBuffer); // Intended })
} .catch((e: Error) => {
} // TODO: FIXME
console.error(e);
}); });
} }
@@ -91,6 +73,10 @@
throw new Error("Failed to download hmac secrets"); throw new Error("Failed to download hmac secrets");
} }
}); });
$effect(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
</script> </script>
<svelte:head> <svelte:head>
@@ -101,17 +87,17 @@
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
{#if data.id !== "root"} {#if data.id !== "root"}
<TopBar title={$info.data?.name} class="flex-shrink-0" /> <TopBar title={$info?.name} class="flex-shrink-0" />
{/if} {/if}
{#if $info.status === "success"} {#if $info}
<div class={["flex flex-grow flex-col px-4 pb-4", data.id === "root" && "pt-4"]}> <div class={["flex flex-grow flex-col px-4 pb-4", data.id === "root" && "pt-4"]}>
<div class="flex gap-x-2"> <div class="flex gap-x-2">
<UploadStatusCard onclick={() => goto("/file/uploads")} /> <UploadStatusCard onclick={() => goto("/file/uploads")} />
<DownloadStatusCard onclick={() => goto("/file/downloads")} /> <DownloadStatusCard onclick={() => goto("/file/downloads")} />
</div> </div>
{#key $info.data.id} {#key $info}
<DirectoryEntries <DirectoryEntries
info={$info.data} info={$info}
onEntryClick={({ type, id }) => goto(`/${type}/${id}`)} onEntryClick={({ type, id }) => goto(`/${type}/${id}`)}
onEntryMenuClick={(entry) => { onEntryMenuClick={(entry) => {
context.selectedEntry = entry; context.selectedEntry = entry;
@@ -144,8 +130,11 @@
<DirectoryCreateModal <DirectoryCreateModal
bind:isOpen={isDirectoryCreateModalOpen} bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => { onCreateClick={async (name) => {
$requestDirectoryCreation.mutate({ name }); if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
return true; // TODO info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}} }}
/> />
<DuplicateFileModal <DuplicateFileModal
@@ -175,45 +164,20 @@
<EntryRenameModal <EntryRenameModal
bind:isOpen={isEntryRenameModalOpen} bind:isOpen={isEntryRenameModalOpen}
onRenameClick={async (newName: string) => { onRenameClick={async (newName: string) => {
if (context.selectedEntry!.type === "directory") { if (await requestEntryRename(context.selectedEntry!, newName)) {
$requestDirectoryRename.mutate({ info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
id: context.selectedEntry!.id, return true;
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 <EntryDeleteModal
bind:isOpen={isEntryDeleteModalOpen} bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => { onDeleteClick={async () => {
if (context.selectedEntry!.type === "directory") { if (await requestEntryDeletion(context.selectedEntry!)) {
const res = await $requestDirectoryDeletion.mutateAsync({ id: context.selectedEntry!.id }); info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
if (!res) return false; return true;
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,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { derived } from "svelte/store"; import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { import {
getDirectoryInfo, getDirectoryInfo,
getFileInfo, getFileInfo,
type DirectoryInfo, type DirectoryInfo,
type SubDirectoryInfo,
type FileInfo, type FileInfo,
} from "$lib/modules/filesystem2"; } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util"; import { SortBy, sortEntries } from "$lib/modules/util";
import { import {
fileUploadStatusStore, fileUploadStatusStore,
@@ -30,84 +30,88 @@
interface DirectoryEntry { interface DirectoryEntry {
name?: string; name?: string;
info: SubDirectoryInfo; info: Writable<DirectoryInfo | null>;
} }
type FileEntry = type FileEntry =
| { | {
type: "file"; type: "file";
name?: string; name?: string;
info: FileInfo; info: Writable<FileInfo | null>;
} }
| { | {
type: "uploading-file"; type: "uploading-file";
name: string; name: string;
info: FileUploadStatus; info: Writable<FileUploadStatus>;
}; };
let subDirectories = $derived( let subDirectories: DirectoryEntry[] = $state([]);
derived( let files: FileEntry[] = $state([]);
info.subDirectoryIds.map((id) => getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!)),
(infos) => { $effect(() => {
const subDirectories = infos // TODO: Fix duplicated requests
.filter(($info) => $info.status === "success")
.map( subDirectories = info.subDirectoryIds.map((id) => {
($info) => const info = getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!);
({ return { name: get(info)?.name, info };
name: $info.data.name, });
info: $info.data as SubDirectoryInfo, files = info.fileIds
}) satisfies DirectoryEntry, .map((id): FileEntry => {
); const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
sortEntries(subDirectories, sortBy); return {
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", type: "file",
name: $info.data.name, name: get(info)?.name,
info: $info.data, info,
}) satisfies FileEntry, };
), })
), .concat(
); $fileUploadStatusStore
let uploadingFiles = $derived( .filter((statusStore) => {
derived($fileUploadStatusStore, (statuses) => const { parentId, status } = get(statusStore);
statuses return parentId === info.id && isFileUploading(status);
.filter(({ parentId, status }) => parentId === info.id && isFileUploading(status)) })
.map( .map((status) => ({
($status) =>
({
type: "uploading-file", type: "uploading-file",
name: $status.name, name: get(status).name,
info: $status, info: status,
}) satisfies FileEntry, })),
),
),
); );
let everyFiles = $derived(
derived([files, uploadingFiles], ([$files, $uploadingFiles]) => { const sort = () => {
const allFiles = [...$files, ...$uploadingFiles]; sortEntries(subDirectories, sortBy);
sortEntries(allFiles, sortBy); sortEntries(files, sortBy);
return allFiles; };
return untrack(() => {
sort();
const unsubscribes = subDirectories
.map((subDirectory) =>
subDirectory.info.subscribe((value) => {
if (subDirectory.name === value?.name) return;
subDirectory.name = value?.name;
sort();
}), }),
)
.concat(
files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
),
); );
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script> </script>
{#if $subDirectories.length + $everyFiles.length > 0} {#if subDirectories.length + files.length > 0}
<div class="space-y-1 pb-[4.5rem]"> <div class="space-y-1 pb-[4.5rem]">
{#each $subDirectories as { info }} {#each subDirectories as { info }}
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} /> <SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{/each} {/each}
{#each $everyFiles as file} {#each files as file}
{#if file.type === "file"} {#if file.type === "file"}
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} /> <File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else} {:else}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
import { callGetApi, callPostApi } from "$lib/hooks"; import { callPostApi } from "$lib/hooks";
import { storeHmacSecrets } from "$lib/indexedDB"; import { storeHmacSecrets } from "$lib/indexedDB";
import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto"; import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
import { import {
@@ -13,10 +13,10 @@ import type {
DirectoryRenameRequest, DirectoryRenameRequest,
DirectoryCreateRequest, DirectoryCreateRequest,
FileRenameRequest, FileRenameRequest,
HmacSecretListResponse,
DirectoryDeleteResponse, DirectoryDeleteResponse,
} from "$lib/server/schemas"; } from "$lib/server/schemas";
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores"; import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
import { useTRPC } from "$trpc/client";
export interface SelectedEntry { export interface SelectedEntry {
type: "directory" | "file"; type: "directory" | "file";
@@ -40,10 +40,16 @@ export const useContext = () => {
export const requestHmacSecretDownload = async (masterKey: CryptoKey) => { export const requestHmacSecretDownload = async (masterKey: CryptoKey) => {
// TODO: MEK rotation // TODO: MEK rotation
const res = await callGetApi("/api/hsk/list"); const trpc = useTRPC();
if (!res.ok) return false;
let hmacSecretsWrapped;
try {
hmacSecretsWrapped = await trpc.hsk.list.query();
} catch {
// TODO: Error Handling
return false;
}
const { hsks: hmacSecretsWrapped }: HmacSecretListResponse = await res.json();
const hmacSecrets = await Promise.all( const hmacSecrets = await Promise.all(
hmacSecretsWrapped.map(async ({ version, state, hsk: hmacSecretWrapped }) => { hmacSecretsWrapped.map(async ({ version, state, hsk: hmacSecretWrapped }) => {
const { hmacSecret } = await unwrapHmacSecret(hmacSecretWrapped, masterKey); const { hmacSecret } = await unwrapHmacSecret(hmacSecretWrapped, masterKey);

View File

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

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { clientListResponse, type ClientListResponse } from "$lib/server/schemas";
import { getUserClientList } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId } = await authorize(locals, "anyClient");
const { userClients } = await getUserClientList(userId);
return json(clientListResponse.parse({ clients: userClients } satisfies ClientListResponse));
};

View File

@@ -1,20 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import {
clientRegisterRequest,
clientRegisterResponse,
type ClientRegisterResponse,
} from "$lib/server/schemas";
import { registerUserClient } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "notClient");
const zodRes = clientRegisterRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { encPubKey, sigPubKey } = zodRes.data;
const { id, challenge } = await registerUserClient(userId, locals.ip, encPubKey, sigPubKey);
return json(clientRegisterResponse.parse({ id, challenge } satisfies ClientRegisterResponse));
};

View File

@@ -1,16 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { clientRegisterVerifyRequest } from "$lib/server/schemas";
import { verifyUserClient } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "notClient");
const zodRes = clientRegisterVerifyRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { id, answerSig } = zodRes.data;
await verifyUserClient(userId, locals.ip, id, answerSig);
return text("Client verified", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,17 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { clientStatusResponse, type ClientStatusResponse } from "$lib/server/schemas";
import { getUserClientStatus } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId, clientId } = await authorize(locals, "anyClient");
const { state, isInitialMekNeeded } = await getUserClientStatus(userId, clientId);
return json(
clientStatusResponse.parse({
id: clientId,
state,
isInitialMekNeeded,
} satisfies ClientStatusResponse),
);
};

View File

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

View File

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

View File

@@ -1,20 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { hmacSecretListResponse, type HmacSecretListResponse } from "$lib/server/schemas";
import { getHskList } from "$lib/server/services/hsk";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId } = await authorize(locals, "activeClient");
const { encHsks } = await getHskList(userId);
return json(
hmacSecretListResponse.parse({
hsks: encHsks.map(({ version, state, mekVersion, encHsk }) => ({
version,
state,
mekVersion,
hsk: encHsk,
})),
} satisfies HmacSecretListResponse),
);
};

View File

@@ -1,16 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { initialHmacSecretRegisterRequest } from "$lib/server/schemas";
import { registerInitialActiveHsk } from "$lib/server/services/hsk";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId, clientId } = await authorize(locals, "activeClient");
const zodRes = initialHmacSecretRegisterRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { mekVersion, hsk } = zodRes.data;
await registerInitialActiveHsk(userId, clientId, mekVersion, hsk);
return text("HSK registered", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,20 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { masterKeyListResponse, type MasterKeyListResponse } from "$lib/server/schemas";
import { getClientMekList } from "$lib/server/services/mek";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId, clientId } = await authorize(locals, "activeClient");
const { encMeks } = await getClientMekList(userId, clientId);
return json(
masterKeyListResponse.parse({
meks: encMeks.map(({ version, state, encMek, encMekSig }) => ({
version,
state,
mek: encMek,
mekSig: encMekSig,
})),
} satisfies MasterKeyListResponse),
);
};

View File

@@ -1,16 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { initialMasterKeyRegisterRequest } from "$lib/server/schemas";
import { registerInitialActiveMek } from "$lib/server/services/mek";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId, clientId } = await authorize(locals, "pendingClient");
const zodRes = initialMasterKeyRegisterRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { mek, mekSig } = zodRes.data;
await registerInitialActiveMek(userId, clientId, mek, mekSig);
return text("MEK registered", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -0,0 +1,15 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { createContext } from "$trpc/init.server";
import { appRouter } from "$trpc/router.server";
import type { RequestHandler } from "./$types";
const trpcHandler: RequestHandler = (event) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req: event.request,
router: appRouter,
createContext: () => createContext(event),
});
export const GET = trpcHandler;
export const POST = trpcHandler;

View File

@@ -1,11 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { userInfoResponse, type UserInfoResponse } from "$lib/server/schemas";
import { getUserInformation } from "$lib/server/services/user";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId } = await authorize(locals, "any");
const { email, nickname } = await getUserInformation(userId);
return json(userInfoResponse.parse({ email, nickname } satisfies UserInfoResponse));
};

View File

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

23
src/trpc/client.ts Normal file
View File

@@ -0,0 +1,23 @@
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { browser } from "$app/environment";
import type { AppRouter } from "./router.server";
const createClient = (fetch: typeof globalThis.fetch) =>
createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: "/api/trpc",
fetch,
}),
],
});
let browserClient: ReturnType<typeof createClient>;
export const useTRPC = (fetch = globalThis.fetch) => {
const client = browserClient ?? createClient(fetch);
if (browser) {
browserClient ??= client;
}
return client;
};

25
src/trpc/init.server.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { RequestEvent } from "@sveltejs/kit";
import { initTRPC, TRPCError } from "@trpc/server";
import { authorizeMiddleware, authorizeClientMiddleware } from "./middlewares/authorize";
export const createContext = (event: RequestEvent) => event;
export const t = initTRPC.context<Awaited<ReturnType<typeof createContext>>>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
const authedProcedure = publicProcedure.use(async ({ ctx, next }) => {
if (!ctx.locals.session) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next();
});
export const roleProcedure = {
any: authedProcedure.use(authorizeMiddleware("any")),
notClient: authedProcedure.use(authorizeMiddleware("notClient")),
anyClient: authedProcedure.use(authorizeClientMiddleware("anyClient")),
pendingClient: authedProcedure.use(authorizeClientMiddleware("pendingClient")),
activeClient: authedProcedure.use(authorizeClientMiddleware("activeClient")),
};

View File

@@ -0,0 +1,36 @@
import { TRPCError } from "@trpc/server";
import {
AuthorizationError,
authorizeInternal,
type ClientSession,
type SessionPermission,
} from "$lib/server/modules/auth";
import { t } from "../init.server";
const authorize = async (locals: App.Locals, requiredPermission: SessionPermission) => {
try {
return await authorizeInternal(locals, requiredPermission);
} catch (e) {
if (e instanceof AuthorizationError) {
throw new TRPCError({
code: e.status === 403 ? "FORBIDDEN" : "INTERNAL_SERVER_ERROR",
message: e.message,
});
}
throw e;
}
};
export const authorizeMiddleware = (requiredPermission: "any" | "notClient") =>
t.middleware(async ({ ctx, next }) => {
const session = await authorize(ctx.locals, requiredPermission);
return next({ ctx: { session } });
});
export const authorizeClientMiddleware = (
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
) =>
t.middleware(async ({ ctx, next }) => {
const session = (await authorize(ctx.locals, requiredPermission)) as ClientSession;
return next({ ctx: { session } });
});

17
src/trpc/router.server.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { RequestEvent } from "@sveltejs/kit";
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import { createContext, router } from "./init.server";
import { clientRouter, hskRouter, mekRouter, userRouter } from "./routers";
export const appRouter = router({
client: clientRouter,
hsk: hskRouter,
mek: mekRouter,
user: userRouter,
});
export const createCaller = (event: RequestEvent) => appRouter.createCaller(createContext(event));
export type AppRouter = typeof appRouter;
export type RouterInputs = inferRouterInputs<AppRouter>;
export type RouterOutputs = inferRouterOutputs<AppRouter>;

View File

@@ -0,0 +1,96 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { ClientRepo, IntegrityError } from "$lib/server/db";
import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto";
import env from "$lib/server/loadenv";
import { router, roleProcedure } from "../init.server";
const createUserClientChallenge = async (
ip: string,
userId: number,
clientId: number,
encPubKey: string,
) => {
const { answer, challenge } = await generateChallenge(32, encPubKey);
const { id } = await ClientRepo.registerUserClientChallenge(
userId,
clientId,
answer.toString("base64"),
ip,
new Date(Date.now() + env.challenge.userClientExp),
);
return { id, challenge: challenge.toString("base64") };
};
const clientRouter = router({
register: roleProcedure["notClient"]
.input(
z.object({
encPubKey: z.string().base64().nonempty(),
sigPubKey: z.string().base64().nonempty(),
}),
)
.mutation(async ({ ctx, input }) => {
const { userId } = ctx.session;
const { encPubKey, sigPubKey } = input;
const client = await ClientRepo.getClientByPubKeys(encPubKey, sigPubKey);
if (client) {
try {
await ClientRepo.createUserClient(userId, client.id);
return await createUserClientChallenge(ctx.locals.ip, userId, client.id, encPubKey);
} catch (e) {
if (e instanceof IntegrityError && e.message === "User client already exists") {
throw new TRPCError({ code: "CONFLICT", message: "Client already registered" });
}
throw e;
}
} else {
if (encPubKey === sigPubKey) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Same public keys" });
} else if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid public key(s)" });
}
try {
const { id: clientId } = await ClientRepo.createClient(encPubKey, sigPubKey, userId);
return await createUserClientChallenge(ctx.locals.ip, userId, clientId, encPubKey);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Public key(s) already registered") {
throw new TRPCError({ code: "CONFLICT", message: "Public key(s) already used" });
}
throw e;
}
}
}),
verify: roleProcedure["notClient"]
.input(
z.object({
id: z.number().int().positive(),
answerSig: z.string().base64().nonempty(),
}),
)
.mutation(async ({ ctx, input }) => {
const challenge = await ClientRepo.consumeUserClientChallenge(
input.id,
ctx.session.userId,
ctx.locals.ip,
);
if (!challenge) {
throw new TRPCError({ code: "FORBIDDEN", message: "Invalid challenge answer" });
}
const client = await ClientRepo.getClient(challenge.clientId);
if (!client) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid challenge answer" });
} else if (
!verifySignature(Buffer.from(challenge.answer, "base64"), input.answerSig, client.sigPubKey)
) {
throw new TRPCError({ code: "FORBIDDEN", message: "Invalid challenge answer signature" });
}
await ClientRepo.setUserClientStateToPending(ctx.session.userId, client.id);
}),
});
export default clientRouter;

41
src/trpc/routers/hsk.ts Normal file
View File

@@ -0,0 +1,41 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { HskRepo, IntegrityError } from "$lib/server/db";
import { router, roleProcedure } from "../init.server";
const hskRouter = router({
list: roleProcedure["activeClient"].query(async ({ ctx }) => {
const hsks = await HskRepo.getAllValidHsks(ctx.session.userId);
return hsks.map(({ version, state, mekVersion, encHsk }) => ({
version,
state,
mekVersion,
hsk: encHsk,
}));
}),
registerInitial: roleProcedure["activeClient"]
.input(
z.object({
mekVersion: z.number().int().positive(),
hsk: z.string().base64().nonempty(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
await HskRepo.registerInitialHsk(
ctx.session.userId,
ctx.session.clientId,
input.mekVersion,
input.hsk,
);
} catch (e) {
if (e instanceof IntegrityError && e.message === "HSK already registered") {
throw new TRPCError({ code: "CONFLICT", message: "Initial HSK already registered" });
}
throw e;
}
}),
});
export default hskRouter;

View File

@@ -0,0 +1,4 @@
export { default as clientRouter } from "./client";
export { default as hskRouter } from "./hsk";
export { default as mekRouter } from "./mek";
export { default as userRouter } from "./user";

63
src/trpc/routers/mek.ts Normal file
View File

@@ -0,0 +1,63 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { ClientRepo, MekRepo, IntegrityError } from "$lib/server/db";
import { verifySignature } from "$lib/server/modules/crypto";
import { router, roleProcedure } from "../init.server";
const verifyClientEncMekSig = async (
userId: number,
clientId: number,
version: number,
encMek: string,
encMekSig: string,
) => {
const userClient = await ClientRepo.getUserClientWithDetails(userId, clientId);
if (!userClient) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" });
}
const data = JSON.stringify({ version, key: encMek });
return verifySignature(Buffer.from(data), encMekSig, userClient.sigPubKey);
};
const mekRouter = router({
list: roleProcedure["activeClient"].query(async ({ ctx }) => {
const clientMeks = await MekRepo.getAllValidClientMeks(
ctx.session.userId,
ctx.session.clientId,
);
return clientMeks.map(({ version, state, encMek, encMekSig }) => ({
version,
state,
mek: encMek,
mekSig: encMekSig,
}));
}),
registerInitial: roleProcedure["pendingClient"]
.input(
z.object({
mek: z.string().base64().nonempty(),
mekSig: z.string().base64().nonempty(),
}),
)
.mutation(async ({ ctx, input }) => {
const { userId, clientId } = ctx.session;
const { mek, mekSig } = input;
if (!(await verifyClientEncMekSig(userId, clientId, 1, mek, mekSig))) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid signature" });
}
try {
await MekRepo.registerInitialMek(userId, clientId, mek, mekSig);
await ClientRepo.setUserClientStateToActive(userId, clientId);
} catch (e) {
if (e instanceof IntegrityError && e.message === "MEK already registered") {
throw new TRPCError({ code: "CONFLICT", message: "Initial MEK already registered" });
}
throw e;
}
}),
});
export default mekRouter;

27
src/trpc/routers/user.ts Normal file
View File

@@ -0,0 +1,27 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { UserRepo } from "$lib/server/db";
import { router, roleProcedure } from "../init.server";
const userRouter = router({
info: roleProcedure.any.query(async ({ ctx }) => {
const user = await UserRepo.getUser(ctx.session.userId);
if (!user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" });
}
return { email: user.email, nickname: user.nickname };
}),
changeNickname: roleProcedure.any
.input(
z.object({
newNickname: z.string().trim().min(2).max(8),
}),
)
.mutation(async ({ ctx, input }) => {
await UserRepo.setUserNickname(ctx.session.userId, input.newNickname);
}),
});
export default userRouter;

View File

@@ -3,15 +3,12 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(), adapter: adapter(),
alias: {
$trpc: "./src/trpc",
},
}, },
}; };