35 Commits

Author SHA1 Message Date
static
d98be331ad 홈, 갤러리, 캐시 설정, 썸네일 설정 페이지에서의 네트워크 호출 최적화 2026-01-01 23:31:01 +09:00
static
841c57e8fc 삭제된 파일의 캐시가 존재하는 경우 캐시 페이지의 로딩이 끝나지 않는 버그 수정 2026-01-01 21:41:53 +09:00
static
182ec18a2b 사소한 리팩토링 2025-12-31 02:43:07 +09:00
static
7b666cf692 파일이 다운로드/업로드된 직후에 다운로드/업로드 페이지의 목록에서 바로 사라지던 버그 수정 2025-12-31 01:32:54 +09:00
static
26323c2d4d 프론트엔드 파일시스템 모듈 리팩토링 2025-12-31 00:43:12 +09:00
static
e4413ddbf6 파일 페이지에서의 네트워크 호출 최적화 2025-12-30 23:30:50 +09:00
static
b5522a4c6d 카테고리 페이지에서의 네트워크 호출 최적화 2025-12-30 20:53:20 +09:00
static
1e57941f4c 디렉터리 페이지에서 하위 디렉터리도 가상 리스트로 표시하도록 개선 2025-12-30 18:44:46 +09:00
static
409ae09f4f 디렉터리 페이지에서의 네트워크 호출 최적화 2025-12-30 17:21:54 +09:00
static
cdb652cacf 사진 또는 동영상이 없을 때 홈 페이지의 레이아웃이 깨지는 버그 수정 2025-12-29 19:43:25 +09:00
static
15b6a53710 사소한 리팩토링 2 2025-12-29 18:14:42 +09:00
static
174305ca1b 파일 페이지와 카테고리 페이지에서 파일 목록을 표시할 때도 가상 리스트를 사용하여 효율적으로 랜더링하도록 개선 2025-12-27 23:27:57 +09:00
static
0d13d3baef 사소한 리팩토링 2025-12-27 14:10:33 +09:00
static
576d41da7f 디렉터리 페이지에 상위 디렉터리로 이동 버튼 추가 2025-12-27 03:04:09 +09:00
static
9eb67d5877 파일 페이지에 다운로드 및 폴더로 이동 메뉴 추가 2025-12-27 02:37:56 +09:00
static
a9da8435cb tRPC 클라이언트에 최대 URL 길이 설정 2025-12-26 23:54:49 +09:00
static
3e98e3d591 갤러리 페이지에서 파일이 표시되지 않던 버그 수정 2025-12-26 23:29:29 +09:00
static
27a46bcc2e eslint.config.js 파일 업데이트 2025-12-26 23:12:37 +09:00
static
a1f30ee154 홈 페이지와 갤러리 페이지에서 사진 및 동영상만 표시되도록 개선 2025-12-26 22:58:09 +09:00
static
6d02178c69 홈 페이지 구현 2025-12-26 22:47:31 +09:00
static
ed21a9cd31 갤러리 페이지 구현 2025-12-26 22:29:44 +09:00
static
b7a7536461 Merge pull request #14 from kmc7468/migrate-to-trpc
tRPC 도입
2025-12-26 15:58:24 +09:00
static
3eb7411438 사소한 리팩토링 3 2025-12-26 15:57:05 +09:00
static
c9d4b10356 사소한 리팩토링 2 2025-12-26 15:45:03 +09:00
static
d94d14cf83 사소한 리팩토링 2025-12-26 15:07:59 +09:00
static
3fc29cf8db /api/auth 아래의 Endpoint들을 tRPC로 마이그레이션 2025-12-25 23:44:23 +09:00
static
b92b4a0b1b Zod 4 마이그레이션 2025-12-25 22:53:51 +09:00
static
6d95059450 /api/category, /api/directory, /api/file 아래의 대부분의 Endpoint들을 tRPC로 마이그레이션 2025-12-25 22:45:55 +09:00
static
a08ddf2c09 tRPC Endpoint를 /api/trpc로 변경 2025-12-25 20:22:58 +09:00
static
208252f6b2 /api/hsk, /api/mek, /api/user 아래의 Endpoint들을 tRPC로 마이그레이션 2025-12-25 20:00:15 +09:00
static
aa4a1a74ea /api/client 아래의 Endpoint들을 tRPC로 마이그레이션 2025-12-25 18:59:41 +09:00
static
640e12d2c3 tRPC Authorization 미들웨어 구현 2025-12-25 16:50:41 +09:00
static
7779910949 tRPC 초기 설정 2025-11-02 23:09:01 +09:00
static
328baba395 패키지 버전 업데이트 2025-11-02 02:57:18 +09:00
static
4e91cdad95 서버로부터 파일의 DEK를 다운로드한 후에야 썸네일이 표시되던 현상 수정 2025-07-20 05:17:38 +09:00
163 changed files with 4474 additions and 4683 deletions

View File

@@ -2,11 +2,7 @@
FROM node:22-alpine AS base
WORKDIR /app
RUN apk add --no-cache bash curl && \
curl -o /usr/local/bin/wait-for-it https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh && \
chmod +x /usr/local/bin/wait-for-it
RUN npm install -g pnpm@9
RUN npm install -g pnpm@10
COPY pnpm-lock.yaml .
# Build Stage
@@ -29,4 +25,4 @@ COPY --from=build /app/build ./build
EXPOSE 3000
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: .
restart: unless-stopped
depends_on:
- database
database:
condition: service_healthy
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
volumes:
- ./data/library:/app/data/library
@@ -35,3 +36,8 @@ services:
environment:
- POSTGRES_USER=arkvault
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5

View File

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

View File

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

2161
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { browser } from "$app/environment";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file";
interface Props {
info: SummarizedFileInfo;
onclick?: (file: SummarizedFileInfo) => void;
}
let { info, onclick }: Props = $props();
let showThumbnail = $derived(
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
);
let thumbnailPromise = $derived(
showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
);
</script>
<button
onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
>
{#await thumbnailPromise}
<div class="h-full w-full bg-gray-100"></div>
{:then thumbnail}
{#if thumbnail}
<img src={thumbnail} alt={info.name} class="h-full w-full object-cover" />
{:else}
<div class="h-full w-full bg-gray-100"></div>
{/if}
{/await}
</button>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import type { DataKey } from "$lib/modules/filesystem";
export interface SelectedCategory {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
dataKey?: DataKey;
name: string;
}

View File

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

View File

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

View File

@@ -1,11 +1,8 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { CheckBox } from "$lib/components/atoms";
import { CheckBox, RowVirtualizer } from "$lib/components/atoms";
import { SubCategories, type SelectedCategory } from "$lib/components/molecules";
import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import type { CategoryInfo } from "$lib/modules/filesystem";
import { sortEntries } from "$lib/utils";
import File from "./File.svelte";
import type { SelectedFile } from "./service";
@@ -13,13 +10,12 @@
interface Props {
info: CategoryInfo;
isFileRecursive: boolean | undefined;
onFileClick: (file: SelectedFile) => void;
onFileRemoveClick: (file: SelectedFile) => void;
onSubCategoryClick: (subCategory: SelectedCategory) => void;
onSubCategoryCreateClick: () => void;
onSubCategoryMenuClick: (subCategory: SelectedCategory) => void;
sortBy?: SortBy;
isFileRecursive: boolean;
}
let {
@@ -29,43 +25,16 @@
onSubCategoryClick,
onSubCategoryCreateClick,
onSubCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
isFileRecursive = $bindable(),
}: Props = $props();
let files: { name?: string; info: Writable<FileInfo | null>; isRecursive: boolean }[] = $state(
[],
);
$effect(() => {
files =
let files = $derived(
sortEntries(
info.files
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive)
.map(({ id, isRecursive }) => {
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
return {
name: get(info)?.name,
info,
isRecursive,
};
}) ?? [];
const sort = () => {
sortEntries(files, sortBy);
};
return untrack(() => {
sort();
const unsubscribes = files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
?.map((file) => ({ name: file.name, details: file }))
.filter(({ details }) => isFileRecursive || !details.isRecursive) ?? [],
),
);
</script>
<div class="space-y-4">
@@ -89,19 +58,24 @@
<p class="font-medium">하위 카테고리의 파일</p>
</CheckBox>
</div>
<div class="space-y-1">
{#key info}
{#each files as { info, isRecursive }}
<RowVirtualizer
count={files.length}
itemHeight={(index) => 48 + (index + 1 < files.length ? 4 : 0)}
>
{#snippet item(index)}
{@const { details } = files[index]!}
<div class={[index + 1 < files.length && "pb-1"]}>
<File
{info}
info={details}
onclick={onFileClick}
onRemoveClick={!isRecursive ? onFileRemoveClick : undefined}
onRemoveClick={!details.isRecursive ? onFileRemoveClick : undefined}
/>
{:else}
<p class="text-gray-500 text-center">이 카테고리에 추가된 파일이 없어요.</p>
{/each}
{/key}
</div>
</div>
{/snippet}
{#snippet placeholder()}
<p class="text-center text-gray-500">이 카테고리에 추가된 파일이 없어요.</p>
{/snippet}
</RowVirtualizer>
</div>
{/if}
</div>

View File

@@ -1,59 +1,38 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { browser } from "$app/environment";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload, type SelectedFile } from "./service";
import type { CategoryFileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file";
import type { SelectedFile } from "./service";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: Writable<FileInfo | null>;
onclick: (selectedFile: SelectedFile) => void;
onRemoveClick?: (selectedFile: SelectedFile) => void;
info: CategoryFileInfo;
onclick: (file: SelectedFile) => void;
onRemoveClick?: (file: SelectedFile) => void;
}
let { info, onclick, onRemoveClick }: Props = $props();
let thumbnail: string | undefined = $state();
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ id, dataKey, dataKeyVersion, name });
};
const removeFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if ($info?.dataKey) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
let showThumbnail = $derived(
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
);
let thumbnailPromise = $derived(
showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
);
</script>
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openFile}
actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={removeFile}
>
<DirectoryEntryLabel type="file" {thumbnail} name={$info.name} />
</ActionEntryButton>
{/if}
<ActionEntryButton
class="h-12"
onclick={() => onclick(info)}
actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={() => onRemoveClick?.(info)}
>
{#await thumbnailPromise}
<DirectoryEntryLabel type="file" name={info.name} />
{:then thumbnail}
<DirectoryEntryLabel type="file" thumbnail={thumbnail ?? undefined} name={info.name} />
{/await}
</ActionEntryButton>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
import { Dexie, type EntityTable } from "dexie";
export type DirectoryId = "root" | number;
interface DirectoryInfo {
id: number;
parentId: DirectoryId;
@@ -18,8 +16,6 @@ interface FileInfo {
categoryIds: number[];
}
export type CategoryId = "root" | number;
interface CategoryInfo {
id: number;
parentId: CategoryId;
@@ -78,6 +74,10 @@ export const getFileInfo = async (id: number) => {
return await filesystem.file.get(id);
};
export const bulkGetFileInfos = async (ids: number[]) => {
return await filesystem.file.bulkGet(ids);
};
export const storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.put(fileInfo);
};

View File

@@ -0,0 +1,95 @@
import axios from "axios";
import { limitFunction } from "p-limit";
import { decryptData } from "$lib/modules/crypto";
export interface FileDownloadState {
id: number;
status:
| "download-pending"
| "downloading"
| "decryption-pending"
| "decrypting"
| "decrypted"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
result?: ArrayBuffer;
}
type LiveFileDownloadState = FileDownloadState & {
status: "download-pending" | "downloading" | "decryption-pending" | "decrypting";
};
let downloadingFiles: FileDownloadState[] = $state([]);
export const isFileDownloading = (
status: FileDownloadState["status"],
): status is LiveFileDownloadState["status"] =>
["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
export const getFileDownloadState = (fileId: number) => {
return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status));
};
export const getDownloadingFiles = () => {
return downloadingFiles.filter((file) => isFileDownloading(file.status));
};
export const clearDownloadedFiles = () => {
downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status));
};
const requestFileDownload = limitFunction(
async (state: FileDownloadState, id: number) => {
state.status = "downloading";
const res = await axios.get(`/api/file/${id}/download`, {
responseType: "arraybuffer",
onDownloadProgress: ({ progress, rate, estimated }) => {
state.progress = progress;
state.rate = rate;
state.estimated = estimated;
},
});
const fileEncrypted: ArrayBuffer = res.data;
state.status = "decryption-pending";
return fileEncrypted;
},
{ concurrency: 1 },
);
const decryptFile = limitFunction(
async (
state: FileDownloadState,
fileEncrypted: ArrayBuffer,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
state.status = "decrypting";
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
state.status = "decrypted";
state.result = fileBuffer;
return fileBuffer;
},
{ concurrency: 4 },
);
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
downloadingFiles.push({
id,
status: "download-pending",
});
const state = downloadingFiles.at(-1)!;
try {
return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey);
} catch (e) {
state.status = "error";
throw e;
}
};

View File

@@ -1,84 +0,0 @@
import axios from "axios";
import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import { decryptData } from "$lib/modules/crypto";
import { fileDownloadStatusStore, type FileDownloadStatus } from "$lib/stores";
const requestFileDownload = limitFunction(
async (status: Writable<FileDownloadStatus>, id: number) => {
status.update((value) => {
value.status = "downloading";
return value;
});
const res = await axios.get(`/api/file/${id}/download`, {
responseType: "arraybuffer",
onDownloadProgress: ({ progress, rate, estimated }) => {
status.update((value) => {
value.progress = progress;
value.rate = rate;
value.estimated = estimated;
return value;
});
},
});
const fileEncrypted: ArrayBuffer = res.data;
status.update((value) => {
value.status = "decryption-pending";
return value;
});
return fileEncrypted;
},
{ concurrency: 1 },
);
const decryptFile = limitFunction(
async (
status: Writable<FileDownloadStatus>,
fileEncrypted: ArrayBuffer,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
status.update((value) => {
value.status = "decrypting";
return value;
});
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
status.update((value) => {
value.status = "decrypted";
value.result = fileBuffer;
return value;
});
return fileBuffer;
},
{ concurrency: 4 },
);
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
const status = writable<FileDownloadStatus>({
id,
status: "download-pending",
});
fileDownloadStatusStore.update((value) => {
value.push(status);
return value;
});
try {
return await decryptFile(
status,
await requestFileDownload(status, id),
fileEncryptedIv,
dataKey,
);
} catch (e) {
status.update((value) => {
value.status = "error";
return value;
});
throw e;
}
};

View File

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

View File

@@ -1,7 +1,6 @@
import axios from "axios";
import ExifReader from "exifreader";
import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import {
encodeToBase64,
generateDataKey,
@@ -13,30 +12,58 @@ import {
} from "$lib/modules/crypto";
import { generateThumbnail } from "$lib/modules/thumbnail";
import type {
DuplicateFileScanRequest,
DuplicateFileScanResponse,
FileThumbnailUploadRequest,
FileUploadRequest,
FileUploadResponse,
} from "$lib/server/schemas";
import {
fileUploadStatusStore,
type MasterKey,
type HmacSecret,
type FileUploadStatus,
} from "$lib/stores";
import type { MasterKey, HmacSecret } from "$lib/stores";
import { trpc } from "$trpc/client";
export interface FileUploadState {
name: string;
parentId: DirectoryId;
status:
| "encryption-pending"
| "encrypting"
| "upload-pending"
| "uploading"
| "uploaded"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
}
export type LiveFileUploadState = FileUploadState & {
status: "encryption-pending" | "encrypting" | "upload-pending" | "uploading";
};
let uploadingFiles: FileUploadState[] = $state([]);
const isFileUploading = (status: FileUploadState["status"]) =>
["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
export const getUploadingFiles = (parentId?: DirectoryId) => {
return uploadingFiles.filter(
(file): file is LiveFileUploadState =>
(parentId === undefined || file.parentId === parentId) && isFileUploading(file.status),
);
};
export const clearUploadedFiles = () => {
uploadingFiles = uploadingFiles.filter((file) => isFileUploading(file.status));
};
const requestDuplicateFileScan = limitFunction(
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
const fileBuffer = await file.arrayBuffer();
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
const res = await axios.post("/api/file/scanDuplicates", {
const files = await trpc().file.listByHash.query({
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
} satisfies DuplicateFileScanRequest);
const { files }: DuplicateFileScanResponse = res.data;
});
if (files.length === 0 || (await onDuplicate())) {
return { fileBuffer, fileSigned };
} else {
@@ -79,16 +106,8 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
};
const encryptFile = limitFunction(
async (
status: Writable<FileUploadStatus>,
file: File,
fileBuffer: ArrayBuffer,
masterKey: MasterKey,
) => {
status.update((value) => {
value.status = "encrypting";
return value;
});
async (state: FileUploadState, file: File, fileBuffer: ArrayBuffer, masterKey: MasterKey) => {
state.status = "encrypting";
const fileType = getFileType(file);
@@ -112,10 +131,7 @@ const encryptFile = limitFunction(
const thumbnailBuffer = await thumbnail?.arrayBuffer();
const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey));
status.update((value) => {
value.status = "upload-pending";
return value;
});
state.status = "upload-pending";
return {
dataKeyWrapped,
@@ -133,20 +149,14 @@ const encryptFile = limitFunction(
);
const requestFileUpload = limitFunction(
async (status: Writable<FileUploadStatus>, form: FormData, thumbnailForm: FormData | null) => {
status.update((value) => {
value.status = "uploading";
return value;
});
async (state: FileUploadState, form: FormData, thumbnailForm: FormData | null) => {
state.status = "uploading";
const res = await axios.post("/api/file/upload", form, {
onUploadProgress: ({ progress, rate, estimated }) => {
status.update((value) => {
value.progress = progress;
value.rate = rate;
value.estimated = estimated;
return value;
});
state.progress = progress;
state.rate = rate;
state.estimated = estimated;
},
});
const { file }: FileUploadResponse = res.data;
@@ -160,10 +170,7 @@ const requestFileUpload = limitFunction(
}
}
status.update((value) => {
value.status = "uploaded";
return value;
});
state.status = "uploaded";
return { fileId: file };
},
@@ -179,15 +186,12 @@ export const uploadFile = async (
): Promise<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
> => {
const status = writable<FileUploadStatus>({
uploadingFiles.push({
name: file.name,
parentId,
status: "encryption-pending",
});
fileUploadStatusStore.update((value) => {
value.push(status);
return value;
});
const state = uploadingFiles.at(-1)!;
try {
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
@@ -196,14 +200,8 @@ export const uploadFile = async (
onDuplicate,
);
if (!fileBuffer || !fileSigned) {
status.update((value) => {
value.status = "canceled";
return value;
});
fileUploadStatusStore.update((value) => {
value = value.filter((v) => v !== status);
return value;
});
state.status = "canceled";
uploadingFiles = uploadingFiles.filter((file) => file !== state);
return undefined;
}
@@ -217,7 +215,7 @@ export const uploadFile = async (
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail,
} = await encryptFile(status, file, fileBuffer, masterKey);
} = await encryptFile(state, file, fileBuffer, masterKey);
const form = new FormData();
form.set(
@@ -255,13 +253,10 @@ export const uploadFile = async (
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
}
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
const { fileId } = await requestFileUpload(state, form, thumbnailForm);
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
} catch (e) {
status.update((value) => {
value.status = "error";
return value;
});
state.status = "error";
throw e;
}
};

View File

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

@@ -0,0 +1,113 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { MaybeCategoryInfo } from "./types";
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo, Partial<MaybeCategoryInfo>>();
const fetchFromIndexedDB = async (id: CategoryId) => {
const [category, subCategories] = await Promise.all([
id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined,
IndexedDB.getCategoryInfos(id),
]);
const files = category
? await Promise.all(
category.files.map(async (file) => {
const fileInfo = await IndexedDB.getFileInfo(file.id);
return fileInfo
? {
id: file.id,
contentType: fileInfo.contentType,
name: fileInfo.name,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
isRecursive: file.isRecursive,
}
: undefined;
}),
)
: undefined;
if (id === "root") {
return {
id,
exists: true as const,
subCategories,
};
} else if (category) {
return {
id,
exists: true as const,
name: category.name,
subCategories,
files: files!.filter((file) => !!file),
isFileRecursive: category.isFileRecursive,
};
}
};
const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => {
try {
const {
metadata,
subCategories: subCategoriesRaw,
files: filesRaw,
} = await trpc().category.get.query({ id });
const [subCategories, files] = await Promise.all([
Promise.all(
subCategoriesRaw.map(async (category) => ({
id: category.id,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
filesRaw
? Promise.all(
filesRaw.map(async (file) => ({
id: file.id,
contentType: file.contentType,
isRecursive: file.isRecursive,
...(await decryptFileMetadata(file, masterKey)),
})),
)
: undefined,
]);
if (id === "root") {
return {
id,
exists: true as const,
subCategories,
};
} else {
return {
id,
exists: true as const,
subCategories,
files,
...(await decryptCategoryMetadata(metadata!, masterKey)),
};
}
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteCategoryInfo(id as number);
return { id, exists: false as const };
}
throw e;
}
};
export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => {
return await cache.get(id, async (isInitial, resolve) => {
if (isInitial) {
const info = await fetchFromIndexedDB(id);
if (info) {
resolve(info);
}
}
const info = await fetchFromServer(id, masterKey);
if (info) {
resolve(info);
}
});
};

View File

@@ -0,0 +1,91 @@
import * as IndexedDB from "$lib/indexedDB";
import { monotonicResolve } from "$lib/utils";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte";
import type { MaybeDirectoryInfo } from "./types";
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>();
const fetchFromIndexedDB = async (id: DirectoryId) => {
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined,
IndexedDB.getDirectoryInfos(id),
IndexedDB.getFileInfos(id),
]);
if (id === "root") {
return {
id,
exists: true as const,
subDirectories,
files,
};
} else if (directory) {
return {
id,
exists: true as const,
parentId: directory.parentId,
name: directory.name,
subDirectories,
files,
};
}
};
const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => {
try {
const {
metadata,
subDirectories: subDirectoriesRaw,
files: filesRaw,
} = await trpc().directory.get.query({ id });
const [subDirectories, files] = await Promise.all([
Promise.all(
subDirectoriesRaw.map(async (directory) => ({
id: directory.id,
...(await decryptDirectoryMetadata(directory, masterKey)),
})),
),
Promise.all(
filesRaw.map(async (file) => ({
id: file.id,
contentType: file.contentType,
...(await decryptFileMetadata(file, masterKey)),
})),
),
]);
if (id === "root") {
return {
id,
exists: true as const,
subDirectories,
files,
};
} else {
return {
id,
exists: true as const,
parentId: metadata!.parent,
subDirectories,
files,
...(await decryptDirectoryMetadata(metadata!, masterKey)),
};
}
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteDirectoryInfo(id as number);
return { id, exists: false as const };
}
throw e;
}
};
export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => {
return await cache.get(id, (isInitial, resolve) =>
monotonicResolve(
[isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)],
resolve,
),
);
};

View File

@@ -0,0 +1,151 @@
import * as IndexedDB from "$lib/indexedDB";
import { monotonicResolve } from "$lib/utils";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { MaybeFileInfo } from "./types";
const cache = new FilesystemCache<number, MaybeFileInfo>();
const fetchFromIndexedDB = async (id: number) => {
const file = await IndexedDB.getFileInfo(id);
const categories = file
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category ? { id: category.id, name: category.name } : undefined;
}),
)
: undefined;
if (file) {
return {
id,
exists: true as const,
parentId: file.parentId,
contentType: file.contentType,
name: file.name,
createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt,
categories: categories!.filter((category) => !!category),
};
}
};
const bulkFetchFromIndexedDB = async (ids: number[]) => {
const files = await IndexedDB.bulkGetFileInfos(ids);
const categories = await Promise.all(
files.map(async (file) =>
file
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category ? { id: category.id, name: category.name } : undefined;
}),
)
: undefined,
),
);
return new Map(
files
.map((file, index) =>
file
? ([
file.id,
{
...file,
exists: true,
categories: categories[index]!.filter((category) => !!category),
},
] as const)
: undefined,
)
.filter((file) => !!file),
);
};
const fetchFromServer = async (id: number, masterKey: CryptoKey) => {
try {
const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id });
const [categories] = await Promise.all([
Promise.all(
categoriesRaw.map(async (category) => ({
id: category.id,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
]);
return {
id,
exists: true as const,
parentId: metadata.parent,
contentType: metadata.contentType,
contentIv: metadata.contentIv,
categories,
...(await decryptFileMetadata(metadata, masterKey)),
};
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteFileInfo(id);
return { id, exists: false as const };
}
throw e;
}
};
const bulkFetchFromServer = async (ids: number[], masterKey: CryptoKey) => {
const filesRaw = await trpc().file.bulkGet.query({ ids });
const files = await Promise.all(
filesRaw.map(async (file) => {
const categories = await Promise.all(
file.categories.map(async (category) => ({
id: category.id,
...(await decryptCategoryMetadata(category, masterKey)),
})),
);
return {
id: file.id,
exists: true as const,
parentId: file.parent,
contentType: file.contentType,
contentIv: file.contentIv,
categories,
...(await decryptFileMetadata(file, masterKey)),
};
}),
);
const existingIds = new Set(filesRaw.map(({ id }) => id));
return new Map<number, MaybeFileInfo>([
...files.map((file) => [file.id, file] as const),
...ids.filter((id) => !existingIds.has(id)).map((id) => [id, { id, exists: false }] as const),
]);
};
export const getFileInfo = async (id: number, masterKey: CryptoKey) => {
return await cache.get(id, (isInitial, resolve) =>
monotonicResolve(
[isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)],
resolve,
),
);
};
export const bulkGetFileInfo = async (ids: number[], masterKey: CryptoKey) => {
return await cache.bulkGet(new Set(ids), (keys, resolve) =>
monotonicResolve(
[
bulkFetchFromIndexedDB(
Array.from(
keys
.entries()
.filter(([, isInitial]) => isInitial)
.map(([key]) => key),
),
),
bulkFetchFromServer(Array.from(keys.keys()), masterKey),
],
resolve,
),
);
};

View File

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

View File

@@ -0,0 +1,130 @@
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
export class FilesystemCache<K, V extends RV, RV = V> {
private map = new Map<K, V | Promise<V>>();
get(key: K, loader: (isInitial: boolean, resolve: (value: RV | undefined) => void) => void) {
const info = this.map.get(key);
if (info instanceof Promise) {
return info;
}
const { promise, resolve } = Promise.withResolvers<V>();
if (!info) {
this.map.set(key, promise);
}
loader(!info, (loadedInfo) => {
if (!loadedInfo) return;
const info = this.map.get(key)!;
if (info instanceof Promise) {
const state = $state(loadedInfo);
this.map.set(key, state as V);
resolve(state as V);
} else {
Object.assign(info, loadedInfo);
resolve(info);
}
});
return info ?? promise;
}
async bulkGet(
keys: Set<K>,
loader: (keys: Map<K, boolean>, resolve: (values: Map<K, RV>) => void) => void,
) {
const states = new Map<K, V>();
const promises = new Map<K, Promise<V>>();
const resolvers = new Map<K, (value: V) => void>();
keys.forEach((key) => {
const info = this.map.get(key);
if (info instanceof Promise) {
promises.set(key, info);
} else if (info) {
states.set(key, info);
} else {
const { promise, resolve } = Promise.withResolvers<V>();
this.map.set(key, promise);
promises.set(key, promise);
resolvers.set(key, resolve);
}
});
loader(
new Map([
...states.keys().map((key) => [key, false] as const),
...resolvers.keys().map((key) => [key, true] as const),
]),
(loadedInfos) =>
loadedInfos.forEach((loadedInfo, key) => {
const info = this.map.get(key)!;
const resolve = resolvers.get(key);
if (info instanceof Promise) {
const state = $state(loadedInfo);
this.map.set(key, state as V);
resolve?.(state as V);
} else {
Object.assign(info, loadedInfo);
resolve?.(info);
}
}),
);
const newStates = await Promise.all(
promises.entries().map(async ([key, promise]) => [key, await promise] as const),
);
return new Map([...states, ...newStates]);
}
}
export const decryptDirectoryMetadata = async (
metadata: { dek: string; dekVersion: Date; name: string; nameIv: string },
masterKey: CryptoKey,
) => {
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
return {
dataKey: { key: dataKey, version: metadata.dekVersion },
name,
};
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
export const decryptFileMetadata = async (
metadata: {
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
createdAt?: string;
createdAtIv?: string;
lastModifiedAt: string;
lastModifiedAtIv: string;
},
masterKey: CryptoKey,
) => {
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const [name, createdAt, lastModifiedAt] = await Promise.all([
decryptString(metadata.name, metadata.nameIv, dataKey),
metadata.createdAt
? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey)
: undefined,
decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
]);
return {
dataKey: { key: dataKey, version: metadata.dekVersion },
name,
createdAt,
lastModifiedAt,
};
};
export const decryptCategoryMetadata = decryptDirectoryMetadata;

View File

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

View File

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

View File

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

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) => {
const userClient = await db
.selectFrom("user_client")

View File

@@ -1,11 +1,10 @@
import { sql, type NotNull } from "kysely";
import { sql } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import pg from "pg";
import { IntegrityError } from "./error";
import db from "./kysely";
import type { Ciphertext } from "./schema";
export type DirectoryId = "root" | number;
interface Directory {
id: number;
parentId: DirectoryId;
@@ -38,6 +37,14 @@ interface File {
export type NewFile = Omit<File, "id">;
interface FileCategory {
id: number;
mekVersion: number;
encDek: string;
dekVersion: Date;
encName: Ciphertext;
}
export const registerDirectory = async (params: NewDirectory) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
@@ -306,39 +313,51 @@ export const getAllFilesByCategory = async (
recurse: boolean,
) => {
const files = await db
.withRecursive("cte", (db) =>
.withRecursive("category_tree", (db) =>
db
.selectFrom("category")
.leftJoin("file_category", "category.id", "file_category.category_id")
.select(["id", "parent_id", "user_id", "file_category.file_id"])
.select(sql<number>`0`.as("depth"))
.select(["id", sql<number>`0`.as("depth")])
.where("id", "=", categoryId)
.where("user_id", "=", userId)
.$if(recurse, (qb) =>
qb.unionAll((db) =>
db
.selectFrom("category")
.leftJoin("file_category", "category.id", "file_category.category_id")
.innerJoin("cte", "category.parent_id", "cte.id")
.select([
"category.id",
"category.parent_id",
"category.user_id",
"file_category.file_id",
])
.select(sql<number>`cte.depth + 1`.as("depth")),
.innerJoin("category_tree", "category.parent_id", "category_tree.id")
.select(["category.id", sql<number>`depth + 1`.as("depth")]),
),
),
)
.selectFrom("cte")
.selectFrom("category_tree")
.innerJoin("file_category", "category_tree.id", "file_category.category_id")
.innerJoin("file", "file_category.file_id", "file.id")
.select(["file_id", "depth"])
.selectAll("file")
.distinctOn("file_id")
.where("user_id", "=", userId)
.where("file_id", "is not", null)
.$narrowType<{ file_id: NotNull }>()
.orderBy("file_id")
.orderBy("depth")
.execute();
return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 }));
return files.map(
(file) =>
({
id: file.file_id,
parentId: file.parent_id ?? "root",
userId: file.user_id,
path: file.path,
mekVersion: file.master_encryption_key_version,
encDek: file.encrypted_data_encryption_key,
dekVersion: file.data_encryption_key_version,
hskVersion: file.hmac_secret_key_version,
contentHmac: file.content_hmac,
contentType: file.content_type,
encContentIv: file.encrypted_content_iv,
encContentHash: file.encrypted_content_hash,
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
isRecursive: file.depth > 0,
}) satisfies File & { isRecursive: boolean },
);
};
export const getAllFileIds = async (userId: number) => {
@@ -390,6 +409,51 @@ export const getFile = async (userId: number, fileId: number) => {
: null;
};
export const getFilesWithCategories = async (userId: number, fileIds: number[]) => {
const files = await db
.selectFrom("file")
.selectAll()
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom("file_category")
.innerJoin("category", "file_category.category_id", "category.id")
.where("file_category.file_id", "=", eb.ref("file.id"))
.selectAll("category"),
).as("categories"),
)
.where("id", "=", (eb) => eb.fn.any(eb.val(fileIds)))
.where("user_id", "=", userId)
.execute();
return files.map(
(file) =>
({
id: file.id,
parentId: file.parent_id ?? "root",
userId: file.user_id,
path: file.path,
mekVersion: file.master_encryption_key_version,
encDek: file.encrypted_data_encryption_key,
dekVersion: file.data_encryption_key_version,
hskVersion: file.hmac_secret_key_version,
contentHmac: file.content_hmac,
contentType: file.content_type,
encContentIv: file.encrypted_content_iv,
encContentHash: file.encrypted_content_hash,
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
categories: file.categories.map((category) => ({
id: category.id,
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: new Date(category.data_encryption_key_version),
encName: category.encrypted_name,
})),
}) satisfies File & { categories: FileCategory[] },
);
};
export const setFileEncName = async (
userId: number,
fileId: number,
@@ -476,10 +540,20 @@ export const addFileToCategory = async (fileId: number, categoryId: number) => {
export const getAllFileCategories = async (fileId: number) => {
const categories = await db
.selectFrom("file_category")
.select("category_id")
.innerJoin("category", "file_category.category_id", "category.id")
.selectAll("category")
.where("file_id", "=", fileId)
.execute();
return categories.map(({ category_id }) => ({ id: category_id }));
return categories.map(
(category) =>
({
id: category.id,
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version,
encName: category.encrypted_name,
}) satisfies FileCategory,
);
};
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {

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) => {
const clientMeks = await db
.selectFrom("client_master_encryption_key")

View File

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

View File

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

View File

@@ -1,20 +1,25 @@
import { error } from "@sveltejs/kit";
import { getUserClient } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { createSession, refreshSession } from "$lib/server/db/session";
import { ClientRepo, SessionRepo, IntegrityError } from "$lib/server/db";
import env from "$lib/server/loadenv";
import { issueSessionId, verifySessionId } from "$lib/server/modules/crypto";
import { verifySessionId } from "$lib/server/modules/crypto";
interface Session {
export interface Session {
sessionId: string;
userId: number;
clientId?: number;
}
interface ClientSession extends Session {
export interface ClientSession extends Session {
clientId: number;
}
export type SessionPermission =
| "any"
| "notClient"
| "anyClient"
| "pendingClient"
| "activeClient";
export class AuthenticationError extends Error {
constructor(
public status: 400 | 401,
@@ -25,11 +30,22 @@ export class AuthenticationError extends Error {
}
}
export const startSession = async (userId: number, ip: string, userAgent: string) => {
const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret);
await createSession(userId, sessionId, ip, userAgent);
return sessionIdSigned;
};
export class AuthorizationError extends Error {
constructor(
public status: 403 | 500,
message: string,
) {
super(message);
this.name = "AuthorizationError";
}
}
export const cookieOptions = {
path: "/",
maxAge: env.session.exp / 1000,
secure: true,
sameSite: "strict",
} as const;
export const authenticate = async (sessionIdSigned: string, ip: string, userAgent: string) => {
const sessionId = verifySessionId(sessionIdSigned, env.session.secret);
@@ -38,7 +54,7 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
}
try {
const { userId, clientId } = await refreshSession(sessionId, ip, userAgent);
const { userId, clientId } = await SessionRepo.refreshSession(sessionId, ip, userAgent);
return {
id: sessionId,
userId,
@@ -52,34 +68,12 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
}
};
export async function authorize(locals: App.Locals, requiredPermission: "any"): Promise<Session>;
export async function authorize(
export const authorizeInternal = async (
locals: App.Locals,
requiredPermission: "notClient",
): Promise<Session>;
export async function authorize(
locals: App.Locals,
requiredPermission: "anyClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "pendingClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "activeClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "any" | "notClient" | "anyClient" | "pendingClient" | "activeClient",
): Promise<Session> {
requiredPermission: SessionPermission,
): Promise<Session> => {
if (!locals.session) {
error(500, "Unauthenticated");
throw new AuthorizationError(500, "Unauthenticated");
}
const { id: sessionId, userId, clientId } = locals.session;
@@ -89,39 +83,63 @@ export async function authorize(
break;
case "notClient":
if (clientId) {
error(403, "Forbidden");
throw new AuthorizationError(403, "Forbidden");
}
break;
case "anyClient":
if (!clientId) {
error(403, "Forbidden");
throw new AuthorizationError(403, "Forbidden");
}
break;
case "pendingClient": {
if (!clientId) {
error(403, "Forbidden");
throw new AuthorizationError(403, "Forbidden");
}
const userClient = await getUserClient(userId, clientId);
const userClient = await ClientRepo.getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
throw new AuthorizationError(500, "Invalid session id");
} else if (userClient.state !== "pending") {
error(403, "Forbidden");
throw new AuthorizationError(403, "Forbidden");
}
break;
}
case "activeClient": {
if (!clientId) {
error(403, "Forbidden");
throw new AuthorizationError(403, "Forbidden");
}
const userClient = await getUserClient(userId, clientId);
const userClient = await ClientRepo.getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
throw new AuthorizationError(500, "Invalid session id");
} else if (userClient.state !== "active") {
error(403, "Forbidden");
throw new AuthorizationError(403, "Forbidden");
}
break;
}
}
return { sessionId, userId, clientId };
};
export async function authorize(
locals: App.Locals,
requiredPermission: "any" | "notClient",
): Promise<Session>;
export async function authorize(
locals: App.Locals,
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: SessionPermission,
): Promise<Session> {
try {
return await authorizeInternal(locals, requiredPermission);
} catch (e) {
if (e instanceof AuthorizationError) {
error(e.status, e.message);
}
throw e;
}
}

View File

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

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

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

View File

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

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

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

View File

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

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,3 @@
export * from "./auth";
export * from "./category";
export * from "./client";
export * from "./directory";
export * from "./file";
export * from "./hsk";
export * from "./mek";
export * from "./user";

View File

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

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

@@ -1,4 +1,3 @@
import { callGetApi } from "$lib/hooks";
import { getAllFileInfos } from "$lib/indexedDB/filesystem";
import { decryptData } from "$lib/modules/crypto";
import {
@@ -11,11 +10,8 @@ import {
downloadFile,
} from "$lib/modules/file";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
import type {
FileThumbnailInfoResponse,
FileThumbnailUploadRequest,
FileListResponse,
} from "$lib/server/schemas";
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
import { trpc } from "$trpc/client";
export const requestFileDownload = async (
fileId: number,
@@ -48,16 +44,20 @@ export const requestFileThumbnailUpload = async (
return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
};
export const requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => {
export const requestFileThumbnailDownload = async (fileId: number, dataKey?: CryptoKey) => {
const cache = await getFileThumbnailCache(fileId);
if (cache) return cache;
if (cache || !dataKey) return cache;
let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
if (!res.ok) return null;
let thumbnailInfo;
try {
thumbnailInfo = await trpc().file.thumbnail.query({ id: fileId });
} catch {
// TODO: Error Handling
return null;
}
const { contentIv: thumbnailEncryptedIv } = thumbnailInfo;
const { contentIv: thumbnailEncryptedIv }: FileThumbnailInfoResponse = await res.json();
res = await callGetApi(`/api/file/${fileId}/thumbnail/download`);
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
if (!res.ok) return null;
const thumbnailEncrypted = await res.arrayBuffer();
@@ -68,10 +68,14 @@ export const requestFileThumbnailDownload = async (fileId: number, dataKey: Cryp
};
export const requestDeletedFilesCleanup = async () => {
const res = await callGetApi("/api/file/list");
if (!res.ok) return;
let liveFiles;
try {
liveFiles = await trpc().file.list.query();
} catch {
// TODO: Error Handling
return;
}
const { files: liveFiles }: FileListResponse = await res.json();
const liveFilesSet = new Set(liveFiles);
const maybeCachedFiles = await getAllFileInfos();

View File

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

View File

@@ -1,49 +0,0 @@
import { writable, type Writable } from "svelte/store";
export interface FileUploadStatus {
name: string;
parentId: "root" | number;
status:
| "encryption-pending"
| "encrypting"
| "upload-pending"
| "uploading"
| "uploaded"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
}
export interface FileDownloadStatus {
id: number;
status:
| "download-pending"
| "downloading"
| "decryption-pending"
| "decrypting"
| "decrypted"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
result?: ArrayBuffer;
}
export const fileUploadStatusStore = writable<Writable<FileUploadStatus>[]>([]);
export const fileDownloadStatusStore = writable<Writable<FileDownloadStatus>[]>([]);
export const isFileUploading = (
status: FileUploadStatus["status"],
): status is "encryption-pending" | "encrypting" | "upload-pending" | "uploading" => {
return ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
};
export const isFileDownloading = (
status: FileDownloadStatus["status"],
): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => {
return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
};

View File

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

2
src/lib/types/filesystem.d.ts vendored Normal file
View File

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

View File

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

4
src/lib/utils/index.ts Normal file
View File

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

16
src/lib/utils/promise.ts Normal file
View File

@@ -0,0 +1,16 @@
export const monotonicResolve = <T>(
promises: (Promise<T> | false)[],
callback: (value: T) => void,
) => {
let latestResolvedIndex = -1;
promises
.filter((promise) => !!promise)
.forEach((promise, index) => {
promise.then((value) => {
if (index > latestResolvedIndex) {
latestResolvedIndex = index;
callback(value);
}
});
});
};

58
src/lib/utils/sort.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,31 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import { isFileDownloading, type FileDownloadStatus } from "$lib/stores";
import { isFileDownloading, type FileDownloadState } from "$lib/modules/file";
import { formatNetworkSpeed } from "$lib/utils";
interface Props {
status?: Writable<FileDownloadStatus>;
state: FileDownloadState;
}
let { status }: Props = $props();
let { state }: Props = $props();
</script>
{#if $status && isFileDownloading($status.status)}
{#if isFileDownloading(state.status)}
<div class="w-full rounded-xl bg-gray-100 p-3">
<p class="font-medium">
{#if $status.status === "download-pending"}
{#if state.status === "download-pending"}
다운로드를 기다리는 중
{:else if $status.status === "downloading"}
{:else if state.status === "downloading"}
다운로드하는 중
{:else if $status.status === "decryption-pending"}
{:else if state.status === "decryption-pending"}
복호화를 기다리는 중
{:else if $status.status === "decrypting"}
{:else if state.status === "decrypting"}
복호화하는 중
{/if}
</p>
<p class="text-xs">
{#if $status.status === "downloading"}
{#if state.status === "downloading"}
전송됨
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
{Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)}
{/if}
</p>
</div>

View File

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

View File

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

View File

@@ -1,19 +1,20 @@
<script lang="ts">
import { get } from "svelte/store";
import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import { fileDownloadStatusStore, isFileDownloading } from "$lib/stores";
import { getDownloadingFiles, clearDownloadedFiles } from "$lib/modules/file";
import { bulkGetFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
let downloadingFiles = $derived(
$fileDownloadStatusStore.filter((status) => isFileDownloading(get(status).status)),
const downloadingFiles = getDownloadingFiles();
const filesPromise = $derived(
bulkGetFileInfo(
downloadingFiles.map(({ id }) => id),
$masterKeyStore?.get(1)?.key!,
),
);
$effect(() => () => {
$fileDownloadStatusStore = $fileDownloadStatusStore.filter((status) =>
isFileDownloading(get(status).status),
);
});
$effect(() => clearDownloadedFiles);
</script>
<svelte:head>
@@ -22,9 +23,14 @@
<TopBar />
<FullscreenDiv>
<div class="space-y-2 pb-4">
{#each downloadingFiles as status}
<File {status} />
{/each}
</div>
{#await filesPromise then files}
<div class="space-y-2 pb-4">
{#each downloadingFiles as state}
{@const info = files.get(state.id)!}
{#if info.exists}
<File {state} {info} />
{/if}
{/each}
</div>
{/await}
</FullscreenDiv>

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import { get, type Writable } from "svelte/store";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { formatNetworkSpeed } from "$lib/modules/util";
import { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
import type { FileDownloadState } from "$lib/modules/file";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatNetworkSpeed } from "$lib/utils";
import IconCloud from "~icons/material-symbols/cloud";
import IconCloudDownload from "~icons/material-symbols/cloud-download";
@@ -12,56 +11,49 @@
import IconError from "~icons/material-symbols/error";
interface Props {
status: Writable<FileDownloadStatus>;
info: FileInfo;
state: FileDownloadState;
}
let { status }: Props = $props();
let fileInfo: Writable<FileInfo | null> | undefined = $state();
$effect(() => {
fileInfo = getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!);
});
let { info, state }: Props = $props();
</script>
{#if $fileInfo}
<div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg text-gray-600">
{#if $status.status === "download-pending"}
<IconCloud />
{:else if $status.status === "downloading"}
<IconCloudDownload />
{:else if $status.status === "decryption-pending"}
<IconLock />
{:else if $status.status === "decrypting"}
<IconLockClock />
{:else if $status.status === "decrypted"}
<IconCheckCircle class="text-green-500" />
{:else if $status.status === "error"}
<IconError class="text-red-500" />
{/if}
</div>
<div class="flex-grow overflow-hidden">
<p title={$fileInfo.name} class="truncate font-medium">
{$fileInfo.name}
</p>
<p class="text-xs text-gray-800">
{#if $status.status === "download-pending"}
다운로드를 기다리는 중
{:else if $status.status === "downloading"}
전송됨
{Math.floor(($status.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
{:else if $status.status === "decryption-pending"}
복호화를 기다리는 중
{:else if $status.status === "decrypting"}
복호화하는 중
{:else if $status.status === "decrypted"}
다운로드 완료
{:else if $status.status === "error"}
다운로드 실패
{/if}
</p>
</div>
<div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg text-gray-600">
{#if state.status === "download-pending"}
<IconCloud />
{:else if state.status === "downloading"}
<IconCloudDownload />
{:else if state.status === "decryption-pending"}
<IconLock />
{:else if state.status === "decrypting"}
<IconLockClock />
{:else if state.status === "decrypted"}
<IconCheckCircle class="text-green-500" />
{:else if state.status === "error"}
<IconError class="text-red-500" />
{/if}
</div>
{/if}
<div class="flex-grow overflow-hidden">
<p title={info.name} class="truncate font-medium">
{info.name}
</p>
<p class="text-xs text-gray-800">
{#if state.status === "download-pending"}
다운로드를 기다리는 중
{:else if state.status === "downloading"}
전송됨
{Math.floor((state.progress ?? 0) * 100)}% ·
{formatNetworkSpeed((state.rate ?? 0) * 8)}
{:else if state.status === "decryption-pending"}
복호화를 기다리는 중
{:else if state.status === "decrypting"}
복호화하는 중
{:else if state.status === "decrypted"}
다운로드 완료
{:else if state.status === "error"}
다운로드 실패
{/if}
</p>
</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import { Gallery } from "$lib/components/organisms";
import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
let { data } = $props();
let files: MaybeFileInfo[] = $state([]);
onMount(async () => {
files = Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values());
});
</script>
<svelte:head>
<title>사진 및 동영상</title>
</svelte:head>
<TopBar title="사진 및 동영상" />
<FullscreenDiv>
<Gallery
files={files.filter((file) => file?.exists)}
onFileClick={({ id }) => goto(`/file/${id}?from=gallery`)}
/>
</FullscreenDiv>

View File

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

View File

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

View File

@@ -1,18 +1,17 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import type { FileCacheIndex } from "$lib/indexedDB";
import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { formatFileSize } from "$lib/modules/util";
import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { formatFileSize } from "$lib/utils";
import File from "./File.svelte";
interface FileCache {
index: FileCacheIndex;
fileInfo: Writable<FileInfo | null>;
info: MaybeFileInfo;
}
let fileCache: FileCache[] | undefined = $state();
@@ -23,11 +22,16 @@
fileCache = fileCache?.filter(({ index }) => index.fileId !== fileId);
};
onMount(() => {
fileCache = getFileCacheIndex()
.map((index) => ({
onMount(async () => {
const indexes = getFileCacheIndex();
const infos = await bulkGetFileInfo(
indexes.map(({ fileId }) => fileId),
$masterKeyStore?.get(1)?.key!,
);
fileCache = indexes
.map((index, i) => ({
index,
fileInfo: getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!),
info: infos.get(index.fileId)!,
}))
.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime());
});
@@ -55,8 +59,8 @@
<p>캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.</p>
</div>
<div class="space-y-2">
{#each fileCache as { index, fileInfo }}
<File {index} info={fileInfo} onDeleteClick={deleteFileCache} />
{#each fileCache as { index, info }}
<File {index} {info} onDeleteClick={deleteFileCache} />
{/each}
</div>
</div>

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms";
import { IconEntryButton, TopBar } from "$lib/components/molecules";
import { deleteAllFileThumbnailCaches } from "$lib/modules/file";
import { getFileInfo } from "$lib/modules/filesystem";
import { bulkGetFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
import {
@@ -20,18 +19,18 @@
const generateAllThumbnails = () => {
persistentStates.files.forEach(({ info }) => {
const fileInfo = get(info);
if (fileInfo) {
requestThumbnailGeneration(fileInfo);
if (info.exists) {
requestThumbnailGeneration(info);
}
});
};
onMount(() => {
persistentStates.files = data.files.map((fileId) => ({
id: fileId,
info: getFileInfo(fileId, $masterKeyStore?.get(1)?.key!),
status: getGenerationStatus(fileId),
onMount(async () => {
const fileInfos = await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!);
persistentStates.files = persistentStates.files.map(({ id, status }) => ({
id,
info: fileInfos.get(id)!,
status,
}));
});
</script>
@@ -57,12 +56,14 @@
</p>
<div class="space-y-2">
{#each persistentStates.files as { info, status }}
<File
{info}
generationStatus={status}
onclick={({ id }) => goto(`/file/${id}`)}
onGenerateThumbnailClick={requestThumbnailGeneration}
/>
{#if info.exists}
<File
{info}
generationStatus={status}
onclick={({ id }) => goto(`/file/${id}`)}
onGenerateThumbnailClick={requestThumbnailGeneration}
/>
{/if}
{/each}
</div>
</div>

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { limitFunction } from "p-limit";
import { get, writable, type Writable } from "svelte/store";
import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file";
import type { FileInfo } from "$lib/modules/filesystem";
import type { FileInfo, MaybeFileInfo } from "$lib/modules/filesystem";
import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail";
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
@@ -17,7 +17,7 @@ export type GenerationStatus =
interface File {
id: number;
info: Writable<FileInfo | null>;
info: MaybeFileInfo;
status?: Writable<GenerationStatus>;
}
@@ -129,7 +129,11 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
let fileSize = 0;
try {
const file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey!);
const file = await requestFileDownload(
fileInfo.id,
fileInfo.contentIv!,
fileInfo.dataKey?.key!,
);
fileSize = file.byteLength;
memoryUsage += fileSize;
@@ -141,11 +145,11 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
status,
file,
fileInfo.contentType,
fileInfo.dataKey!,
fileInfo.dataKey?.key!,
);
if (
!thumbnail ||
!(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail))
!(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKey?.version!, thumbnail))
) {
status.set("error");
}

View File

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

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components/molecules";
import { Category, CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, updateCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { getCategoryInfo, type MaybeCategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
@@ -19,9 +18,7 @@
let { data } = $props();
let context = createContext();
let info: Writable<CategoryInfo | null> | undefined = $state();
let isFileRecursive: boolean | undefined = $state();
let infoPromise: Promise<MaybeCategoryInfo> | undefined = $state();
let isCategoryCreateModalOpen = $state(false);
let isCategoryMenuBottomSheetOpen = $state(false);
@@ -29,20 +26,7 @@
let isCategoryDeleteModalOpen = $state(false);
$effect(() => {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
isFileRecursive = undefined;
});
$effect(() => {
if ($info && isFileRecursive === undefined) {
isFileRecursive = $info.isFileRecursive ?? false;
}
});
$effect(() => {
if (data.id !== "root" && $info?.isFileRecursive !== isFileRecursive) {
updateCategoryInfo(data.id as number, { isFileRecursive });
}
infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
</script>
@@ -50,68 +34,70 @@
<title>카테고리</title>
</svelte:head>
{#if data.id !== "root"}
<TopBar title={$info?.name} />
{/if}
<div class="min-h-full bg-gray-100 pb-[5.5em]">
{#if $info && isFileRecursive !== undefined}
<Category
bind:isFileRecursive
info={$info}
onFileClick={({ id }) => goto(`/file/${id}`)}
onFileRemoveClick={async ({ id }) => {
await requestFileRemovalFromCategory(id, data.id as number);
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
{#await infoPromise then info}
{#if info?.exists}
{#if info.id !== "root"}
<TopBar title={info.name} />
{/if}
<div class="min-h-full bg-gray-100 pb-[5.5em]">
<Category
bind:isFileRecursive={info.isFileRecursive}
{info}
onFileClick={({ id }) => goto(`/file/${id}?from=category`)}
onFileRemoveClick={async ({ id }) => {
await requestFileRemovalFromCategory(id, data.id as number);
infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}}
onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
onSubCategoryMenuClick={(subCategory) => {
context.selectedCategory = subCategory;
isCategoryMenuBottomSheetOpen = true;
}}
/>
</div>
<CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
onSubCategoryMenuClick={(subCategory) => {
context.selectedCategory = subCategory;
isCategoryMenuBottomSheetOpen = true;
/>
<CategoryMenuBottomSheet
bind:isOpen={isCategoryMenuBottomSheetOpen}
onRenameClick={() => {
isCategoryMenuBottomSheetOpen = false;
isCategoryRenameModalOpen = true;
}}
onDeleteClick={() => {
isCategoryMenuBottomSheetOpen = false;
isCategoryDeleteModalOpen = true;
}}
/>
<CategoryRenameModal
bind:isOpen={isCategoryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (await requestCategoryRename(context.selectedCategory!, newName)) {
infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<CategoryDeleteModal
bind:isOpen={isCategoryDeleteModalOpen}
onDeleteClick={async () => {
if (await requestCategoryDeletion(context.selectedCategory!)) {
infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
{/if}
</div>
<CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<CategoryMenuBottomSheet
bind:isOpen={isCategoryMenuBottomSheetOpen}
onRenameClick={() => {
isCategoryMenuBottomSheetOpen = false;
isCategoryRenameModalOpen = true;
}}
onDeleteClick={() => {
isCategoryMenuBottomSheetOpen = false;
isCategoryDeleteModalOpen = true;
}}
/>
<CategoryRenameModal
bind:isOpen={isCategoryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (await requestCategoryRename(context.selectedCategory!, newName)) {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<CategoryDeleteModal
bind:isOpen={isCategoryDeleteModalOpen}
onDeleteClick={async () => {
if (await requestCategoryDeletion(context.selectedCategory!)) {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
{/await}

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { FloatingButton } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
import { getDirectoryInfo, type MaybeDirectoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries";
@@ -29,7 +29,7 @@
let { data } = $props();
let context = createContext();
let info: Writable<DirectoryInfo | null> | undefined = $state();
let infoPromise: Promise<MaybeDirectoryInfo> | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state();
let duplicatedFile: File | undefined = $state();
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
@@ -42,6 +42,9 @@
let isEntryRenameModalOpen = $state(false);
let isEntryDeleteModalOpen = $state(false);
let isFromFilePage = $derived(page.url.searchParams.get("from") === "file");
let showTopBar = $derived(data.id !== "root" || isFromFilePage);
const uploadFile = () => {
const files = fileInput?.files;
if (!files || files.length === 0) return;
@@ -57,7 +60,7 @@
.then((res) => {
if (!res) return;
// TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
})
.catch((e: Error) => {
// TODO: FIXME
@@ -75,7 +78,7 @@
});
$effect(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
</script>
@@ -85,99 +88,106 @@
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
<div class="flex h-full flex-col">
{#if data.id !== "root"}
<TopBar title={$info?.name} class="flex-shrink-0" />
{/if}
{#if $info}
<div class={["flex flex-grow flex-col px-4 pb-4", data.id === "root" && "pt-4"]}>
<div class="flex gap-x-2">
<UploadStatusCard onclick={() => goto("/file/uploads")} />
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
</div>
{#key $info}
{#await infoPromise then info}
{#if info?.exists}
<div class="flex h-full flex-col">
{#if showTopBar}
<TopBar title={info.name} class="flex-shrink-0" />
{/if}
<div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "pt-4"]}>
<div class="flex gap-x-2">
<UploadStatusCard onclick={() => goto("/file/uploads")} />
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
</div>
<DirectoryEntries
info={$info}
{info}
onEntryClick={({ type, id }) => goto(`/${type}/${id}`)}
onEntryMenuClick={(entry) => {
context.selectedEntry = entry;
isEntryMenuBottomSheetOpen = true;
}}
showParentEntry={isFromFilePage && info.parentId !== undefined}
onParentClick={() =>
goto(
info.parentId === "root"
? "/directory?from=file"
: `/directory/${info.parentId}?from=file`,
)}
/>
{/key}
</div>
</div>
<FloatingButton
icon={IconAdd}
onclick={() => {
isEntryCreateBottomSheetOpen = true;
}}
class="bottom-24 right-4"
/>
<EntryCreateBottomSheet
bind:isOpen={isEntryCreateBottomSheetOpen}
onDirectoryCreateClick={() => {
isEntryCreateBottomSheetOpen = false;
isDirectoryCreateModalOpen = true;
}}
onFileUploadClick={() => {
isEntryCreateBottomSheetOpen = false;
fileInput?.click();
}}
/>
<DirectoryCreateModal
bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => {
if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<DuplicateFileModal
bind:isOpen={isDuplicateFileModalOpen}
file={duplicatedFile}
onbeforeclose={() => {
resolveForDuplicateFileModal?.(false);
isDuplicateFileModalOpen = false;
}}
onUploadClick={() => {
resolveForDuplicateFileModal?.(true);
isDuplicateFileModalOpen = false;
}}
/>
<EntryMenuBottomSheet
bind:isOpen={isEntryMenuBottomSheetOpen}
onRenameClick={() => {
isEntryMenuBottomSheetOpen = false;
isEntryRenameModalOpen = true;
}}
onDeleteClick={() => {
isEntryMenuBottomSheetOpen = false;
isEntryDeleteModalOpen = true;
}}
/>
<EntryRenameModal
bind:isOpen={isEntryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (await requestEntryRename(context.selectedEntry!, newName)) {
infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<EntryDeleteModal
bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => {
if (await requestEntryDeletion(context.selectedEntry!)) {
infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
{/if}
</div>
<FloatingButton
icon={IconAdd}
onclick={() => {
isEntryCreateBottomSheetOpen = true;
}}
class="bottom-24 right-4"
/>
<EntryCreateBottomSheet
bind:isOpen={isEntryCreateBottomSheetOpen}
onDirectoryCreateClick={() => {
isEntryCreateBottomSheetOpen = false;
isDirectoryCreateModalOpen = true;
}}
onFileUploadClick={() => {
isEntryCreateBottomSheetOpen = false;
fileInput?.click();
}}
/>
<DirectoryCreateModal
bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => {
if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<DuplicateFileModal
bind:isOpen={isDuplicateFileModalOpen}
file={duplicatedFile}
onbeforeclose={() => {
resolveForDuplicateFileModal?.(false);
isDuplicateFileModalOpen = false;
}}
onUploadClick={() => {
resolveForDuplicateFileModal?.(true);
isDuplicateFileModalOpen = false;
}}
/>
<EntryMenuBottomSheet
bind:isOpen={isEntryMenuBottomSheetOpen}
onRenameClick={() => {
isEntryMenuBottomSheetOpen = false;
isEntryRenameModalOpen = true;
}}
onDeleteClick={() => {
isEntryMenuBottomSheetOpen = false;
isEntryDeleteModalOpen = true;
}}
/>
<EntryRenameModal
bind:isOpen={isEntryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (await requestEntryRename(context.selectedEntry!, newName)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<EntryDeleteModal
bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => {
if (await requestEntryDeletion(context.selectedEntry!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
{/await}

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