Merge pull request #8 from kmc7468/add-file-category

카테고리를 활용한 파일 분류 시스템 도입
This commit is contained in:
static
2025-01-23 13:31:12 +09:00
committed by GitHub
59 changed files with 1941 additions and 155 deletions

View File

@@ -3,8 +3,5 @@ package-lock.json
pnpm-lock.yaml
yarn.lock
# Output
/drizzle
# Documents
*.md

View File

@@ -5,13 +5,14 @@
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:db": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"db:up": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d",
"db:down": "docker compose -f docker-compose.dev.yaml -p arkvault-dev down",
"db:migrate": "kysely migrate"
},
"devDependencies": {
@@ -20,7 +21,6 @@
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/better-sqlite3": "^7.6.12",
"@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.7",
@@ -42,7 +42,7 @@
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"svelte": "^5.17.1",
"svelte": "^5.19.1",
"svelte-check": "^4.1.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",

92
pnpm-lock.yaml generated
View File

@@ -41,16 +41,13 @@ importers:
version: 1.2.12
'@sveltejs/adapter-node':
specifier: ^5.2.11
version: 5.2.11(@sveltejs/kit@2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))
version: 5.2.11(@sveltejs/kit@2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))
'@sveltejs/kit':
specifier: ^2.15.2
version: 2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
version: 2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
'@sveltejs/vite-plugin-svelte':
specifier: ^4.0.4
version: 4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
'@types/better-sqlite3':
specifier: ^7.6.12
version: 7.6.12
version: 4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
'@types/file-saver':
specifier: ^2.0.7
version: 2.0.7
@@ -80,7 +77,7 @@ importers:
version: 9.1.0(eslint@9.17.0(jiti@2.4.2))
eslint-plugin-svelte:
specifier: ^2.46.1
version: 2.46.1(eslint@9.17.0(jiti@2.4.2))(svelte@5.17.1)
version: 2.46.1(eslint@9.17.0(jiti@2.4.2))(svelte@5.19.1)
eslint-plugin-tailwindcss:
specifier: ^3.17.5
version: 3.17.5(tailwindcss@3.4.17)
@@ -110,16 +107,16 @@ importers:
version: 3.4.2
prettier-plugin-svelte:
specifier: ^3.3.2
version: 3.3.2(prettier@3.4.2)(svelte@5.17.1)
version: 3.3.2(prettier@3.4.2)(svelte@5.19.1)
prettier-plugin-tailwindcss:
specifier: ^0.6.9
version: 0.6.9(prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.17.1))(prettier@3.4.2)
version: 0.6.9(prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.19.1))(prettier@3.4.2)
svelte:
specifier: ^5.17.1
version: 5.17.1
specifier: ^5.19.1
version: 5.19.1
svelte-check:
specifier: ^4.1.3
version: 4.1.3(picomatch@4.0.2)(svelte@5.17.1)(typescript@5.7.3)
version: 4.1.3(picomatch@4.0.2)(svelte@5.19.1)(typescript@5.7.3)
tailwindcss:
specifier: ^3.4.17
version: 3.4.17
@@ -131,7 +128,7 @@ importers:
version: 8.19.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.7.3)
unplugin-icons:
specifier: ^0.22.0
version: 0.22.0(svelte@5.17.1)
version: 0.22.0(svelte@5.19.1)
vite:
specifier: ^5.4.11
version: 5.4.11(@types/node@22.10.5)
@@ -717,9 +714,6 @@ packages:
svelte: ^5.0.0-next.96 || ^5.0.0
vite: ^5.0.0
'@types/better-sqlite3@7.6.12':
resolution: {integrity: sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==}
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
@@ -1120,8 +1114,8 @@ packages:
resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
engines: {node: '>=0.10'}
esrap@1.3.2:
resolution: {integrity: sha512-C4PXusxYhFT98GjLSmb20k9PREuUdporer50dhzGuJu9IJXktbMddVCMLAERl5dAHyAi73GWWCE4FVHGP1794g==}
esrap@1.4.3:
resolution: {integrity: sha512-Xddc1RsoFJ4z9nR7W7BFaEPIp4UXoeQ0+077UdWLxbafMQFyU79sQJMk7kxNgRwQ9/aVgaKacCHC2pUACGwmYw==}
esrecurse@4.3.0:
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
@@ -1998,8 +1992,8 @@ packages:
svelte:
optional: true
svelte@5.17.1:
resolution: {integrity: sha512-HitqD0XhU9OEytPuux/XYzxle4+7D8+fIb1tHbwMzOtBzDZZO+ESEuwMbahJ/3JoklfmRPB/Gzp74L87Qrxfpw==}
svelte@5.19.1:
resolution: {integrity: sha512-H/Vs2O51bwILZbaNUSdr4P1NbLpOGsxl4jJAjd88ELjzRgeRi1BHqexcVGannDr7D1pmTYWWajzHOM7bMbtB9Q==}
engines: {node: '>=18'}
tailwindcss@3.4.17:
@@ -2579,17 +2573,17 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.30.1':
optional: true
'@sveltejs/adapter-node@5.2.11(@sveltejs/kit@2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))':
'@sveltejs/adapter-node@5.2.11(@sveltejs/kit@2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))':
dependencies:
'@rollup/plugin-commonjs': 28.0.2(rollup@4.30.1)
'@rollup/plugin-json': 6.1.0(rollup@4.30.1)
'@rollup/plugin-node-resolve': 16.0.0(rollup@4.30.1)
'@sveltejs/kit': 2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
'@sveltejs/kit': 2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
rollup: 4.30.1
'@sveltejs/kit@2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))':
'@sveltejs/kit@2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))':
dependencies:
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
'@types/cookie': 0.6.0
cookie: 0.6.0
devalue: 5.1.1
@@ -2601,36 +2595,32 @@ snapshots:
sade: 1.8.1
set-cookie-parser: 2.7.1
sirv: 3.0.0
svelte: 5.17.1
svelte: 5.19.1
tiny-glob: 0.2.9
vite: 5.4.11(@types/node@22.10.5)
'@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))':
'@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))':
dependencies:
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
debug: 4.4.0
svelte: 5.17.1
svelte: 5.19.1
vite: 5.4.11(@types/node@22.10.5)
transitivePeerDependencies:
- supports-color
'@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))':
'@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
'@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
debug: 4.4.0
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.17
svelte: 5.17.1
svelte: 5.19.1
vite: 5.4.11(@types/node@22.10.5)
vitefu: 1.0.5(vite@5.4.11(@types/node@22.10.5))
transitivePeerDependencies:
- supports-color
'@types/better-sqlite3@7.6.12':
dependencies:
'@types/node': 22.10.5
'@types/cookie@0.6.0': {}
'@types/estree@1.0.6': {}
@@ -3011,7 +3001,7 @@ snapshots:
dependencies:
eslint: 9.17.0(jiti@2.4.2)
eslint-plugin-svelte@2.46.1(eslint@9.17.0(jiti@2.4.2))(svelte@5.17.1):
eslint-plugin-svelte@2.46.1(eslint@9.17.0(jiti@2.4.2))(svelte@5.19.1):
dependencies:
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@2.4.2))
'@jridgewell/sourcemap-codec': 1.5.0
@@ -3024,9 +3014,9 @@ snapshots:
postcss-safe-parser: 6.0.0(postcss@8.4.49)
postcss-selector-parser: 6.1.2
semver: 7.6.3
svelte-eslint-parser: 0.43.0(svelte@5.17.1)
svelte-eslint-parser: 0.43.0(svelte@5.19.1)
optionalDependencies:
svelte: 5.17.1
svelte: 5.19.1
transitivePeerDependencies:
- ts-node
@@ -3109,7 +3099,7 @@ snapshots:
dependencies:
estraverse: 5.3.0
esrap@1.3.2:
esrap@1.4.3:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
@@ -3693,16 +3683,16 @@ snapshots:
prelude-ls@1.2.1: {}
prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.17.1):
prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.19.1):
dependencies:
prettier: 3.4.2
svelte: 5.17.1
svelte: 5.19.1
prettier-plugin-tailwindcss@0.6.9(prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.17.1))(prettier@3.4.2):
prettier-plugin-tailwindcss@0.6.9(prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.19.1))(prettier@3.4.2):
dependencies:
prettier: 3.4.2
optionalDependencies:
prettier-plugin-svelte: 3.3.2(prettier@3.4.2)(svelte@5.17.1)
prettier-plugin-svelte: 3.3.2(prettier@3.4.2)(svelte@5.19.1)
prettier@3.4.2: {}
@@ -3838,19 +3828,19 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svelte-check@4.1.3(picomatch@4.0.2)(svelte@5.17.1)(typescript@5.7.3):
svelte-check@4.1.3(picomatch@4.0.2)(svelte@5.19.1)(typescript@5.7.3):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
chokidar: 4.0.3
fdir: 6.4.2(picomatch@4.0.2)
picocolors: 1.1.1
sade: 1.8.1
svelte: 5.17.1
svelte: 5.19.1
typescript: 5.7.3
transitivePeerDependencies:
- picomatch
svelte-eslint-parser@0.43.0(svelte@5.17.1):
svelte-eslint-parser@0.43.0(svelte@5.19.1):
dependencies:
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3
@@ -3858,9 +3848,9 @@ snapshots:
postcss: 8.4.49
postcss-scss: 4.0.9(postcss@8.4.49)
optionalDependencies:
svelte: 5.17.1
svelte: 5.19.1
svelte@5.17.1:
svelte@5.19.1:
dependencies:
'@ampproject/remapping': 2.3.0
'@jridgewell/sourcemap-codec': 1.5.0
@@ -3871,7 +3861,7 @@ snapshots:
axobject-query: 4.1.0
clsx: 2.1.1
esm-env: 1.2.2
esrap: 1.3.2
esrap: 1.4.3
is-reference: 3.0.3
locate-character: 3.0.0
magic-string: 0.30.17
@@ -3967,7 +3957,7 @@ snapshots:
undici-types@6.20.0: {}
unplugin-icons@0.22.0(svelte@5.17.1):
unplugin-icons@0.22.0(svelte@5.19.1):
dependencies:
'@antfu/install-pkg': 0.5.0
'@antfu/utils': 0.7.10
@@ -3977,7 +3967,7 @@ snapshots:
local-pkg: 0.5.1
unplugin: 2.1.2
optionalDependencies:
svelte: 5.17.1
svelte: 5.19.1
transitivePeerDependencies:
- supports-color

View File

@@ -28,7 +28,7 @@
<AdaptiveDiv>
<div
onclick={(e) => e.stopPropagation()}
class="flex max-h-[70vh] min-h-[30vh] rounded-t-2xl bg-white px-4"
class="flex max-h-[70vh] min-h-[30vh] overflow-y-auto rounded-t-2xl bg-white px-4"
transition:fly={{ y: 100, duration: 200 }}
>
{@render children?.()}

View File

@@ -7,16 +7,20 @@
children?: Snippet;
onback?: () => void;
title?: string;
xPadding?: boolean;
}
let { children, onback, title }: Props = $props();
let { children, onback, title, xPadding = false }: Props = $props();
const back = $derived(() => {
setTimeout(onback || (() => history.back()), 100);
});
</script>
<div class="sticky top-0 z-10 flex flex-shrink-0 items-center justify-between bg-white py-4">
<div
class="sticky top-0 z-10 flex flex-shrink-0 items-center justify-between bg-white py-4
{xPadding ? 'px-4' : ''}"
>
<button onclick={back} class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-gray-100">
<IconArrowBack class="text-2xl" />
</button>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from "svelte";
import IconCheckCircle from "~icons/material-symbols/check-circle";
import IconCheckCircleOutline from "~icons/material-symbols/check-circle-outline";
interface Props {
children: Snippet;
checked?: boolean;
}
let { children, checked = $bindable(false) }: Props = $props();
</script>
<label class="flex items-center gap-x-1">
<input bind:checked type="checkbox" class="hidden" />
{@render children?.()}
{#if checked}
<IconCheckCircle class="text-primary-600" />
{:else}
<IconCheckCircleOutline class="text-gray-300" />
{/if}
</label>

View File

@@ -1 +1,2 @@
export { default as CheckBox } from "./CheckBox.svelte";
export { default as TextInput } from "./TextInput.svelte";

View File

@@ -15,16 +15,28 @@ interface FileInfo {
contentType: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
export type CategoryId = "root" | number;
interface CategoryInfo {
id: number;
parentId: CategoryId;
name: string;
files: { id: number; isRecursive: boolean }[];
}
const filesystem = new Dexie("filesystem") as Dexie & {
directory: EntityTable<DirectoryInfo, "id">;
file: EntityTable<FileInfo, "id">;
category: EntityTable<CategoryInfo, "id">;
};
filesystem.version(1).stores({
filesystem.version(2).stores({
directory: "id, parentId",
file: "id, parentId",
category: "id, parentId",
});
export const getDirectoryInfos = async (parentId: DirectoryId) => {
@@ -59,13 +71,29 @@ export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id);
};
export const getCategoryInfos = async (parentId: CategoryId) => {
return await filesystem.category.where({ parentId }).toArray();
};
export const getCategoryInfo = async (id: number) => {
return await filesystem.category.get(id);
};
export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.put(categoryInfo);
};
export const deleteCategoryInfo = async (id: number) => {
await filesystem.category.delete(id);
};
export const cleanupDanglingInfos = async () => {
const validDirectoryIds: number[] = [];
const validFileIds: number[] = [];
const queue: DirectoryId[] = ["root"];
const directoryQueue: DirectoryId[] = ["root"];
while (true) {
const directoryId = queue.shift();
const directoryId = directoryQueue.shift();
if (!directoryId) break;
const [subDirectories, files] = await Promise.all([
@@ -74,13 +102,28 @@ export const cleanupDanglingInfos = async () => {
]);
subDirectories.forEach(({ id }) => {
validDirectoryIds.push(id);
queue.push(id);
directoryQueue.push(id);
});
files.forEach(({ id }) => validFileIds.push(id));
}
const validCategoryIds: number[] = [];
const categoryQueue: CategoryId[] = ["root"];
while (true) {
const categoryId = categoryQueue.shift();
if (!categoryId) break;
const subCategories = await filesystem.category.where({ parentId: categoryId }).toArray();
subCategories.forEach(({ id }) => {
validCategoryIds.push(id);
categoryQueue.push(id);
});
}
await Promise.all([
filesystem.directory.where("id").noneOf(validDirectoryIds).delete(),
filesystem.file.where("id").noneOf(validFileIds).delete(),
filesystem.category.where("id").noneOf(validCategoryIds).delete(),
]);
};

View File

@@ -9,10 +9,20 @@ import {
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
deleteCategoryInfo,
type DirectoryId,
type CategoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
import type {
CategoryInfoResponse,
CategoryFileListResponse,
DirectoryInfoResponse,
FileInfoResponse,
} from "$lib/server/schemas";
export type DirectoryInfo =
| {
@@ -41,10 +51,30 @@ export interface FileInfo {
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
export type CategoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subCategoryIds: number[];
files?: undefined;
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subCategoryIds: number[];
files: { id: number; isRecursive: 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,
@@ -124,7 +154,7 @@ export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
directoryInfoStore.set(id, info);
}
fetchDirectoryInfo(id, info, masterKey);
fetchDirectoryInfo(id, info, masterKey); // Intended
return info;
};
@@ -178,6 +208,7 @@ const fetchFileInfoFromServer = async (
name,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
await storeFileInfo({
id,
@@ -186,6 +217,7 @@ const fetchFileInfoFromServer = async (
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
};
@@ -203,6 +235,95 @@ export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
fileInfoStore.set(fileId, info);
}
fetchFileInfo(fileId, info, masterKey);
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 });
}
};
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 }));
info.set({
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subCategoryIds: subCategories,
files: filesMapped,
});
await storeCategoryInfo({
id,
parentId: metadata!.parent,
name,
files: filesMapped,
});
}
};
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;
};

View File

@@ -27,3 +27,32 @@ export const formatNetworkSpeed = (speed: number) => {
if (speed < 1000 * 1000 * 1000) return `${(speed / 1000 / 1000).toFixed(1)} Mbps`;
return `${(speed / 1000 / 1000 / 1000).toFixed(1)} Gbps`;
};
export enum SortBy {
NAME_ASC,
NAME_DESC,
}
type SortFunc = (a?: string, b?: string) => number;
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
const sortByNameAsc: SortFunc = (a, b) => {
if (a && b) return collator.compare(a, b);
if (a) return -1;
if (b) return 1;
return 0;
};
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
export const sortEntries = <T extends { name?: string }>(entries: T[], sortBy: SortBy) => {
let sortFunc: SortFunc;
if (sortBy === SortBy.NAME_ASC) {
sortFunc = sortByNameAsc;
} else {
sortFunc = sortByNameDesc;
}
entries.sort((a, b) => sortFunc(a.name, b.name));
};

View File

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

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import type { CategoryInfo } from "$lib/modules/filesystem";
import type { SelectedCategory } from "./service";
import IconCategory from "~icons/material-symbols/category";
interface Props {
info: Writable<CategoryInfo | null>;
menuIcon?: Component<SvelteHTMLElements["svg"]>;
onclick: (category: SelectedCategory) => void;
onMenuClick?: (category: SelectedCategory) => void;
}
let { info, menuIcon: MenuIcon, onclick, onMenuClick }: Props = $props();
const openCategory = () => {
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
setTimeout(() => {
onclick({ id, dataKey, dataKeyVersion, name });
}, 100);
};
const openMenu = (e: Event) => {
e.stopPropagation();
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
setTimeout(() => {
onMenuClick!({ id, dataKey, dataKeyVersion, name });
}, 100);
};
</script>
{#if $info}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div id="button" onclick={openCategory} class="h-12 rounded-xl">
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
<div class="flex-shrink-0 text-lg">
<IconCategory />
</div>
<p title={$info.name} class="flex-grow truncate font-medium">
{$info.name}
</p>
{#if MenuIcon && onMenuClick}
<button
id="open-menu"
onclick={openMenu}
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
>
<MenuIcon class="text-lg" />
</button>
{/if}
</div>
</div>
{/if}
<style>
#button:active:not(:has(#open-menu:active)) {
@apply bg-gray-100;
}
#button-content:active:not(:has(#open-menu:active)) {
@apply scale-95;
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { EntryButton } from "$lib/components/buttons";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import Categories, { type SelectedCategory } from "$lib/molecules/Categories";
import { masterKeyStore } from "$lib/stores";
import IconAddCircle from "~icons/material-symbols/add-circle";
interface Props {
class?: ClassValue;
info: CategoryInfo;
onSubCategoryClick: (subCategory: SelectedCategory) => void;
onSubCategoryCreateClick: () => void;
onSubCategoryMenuClick?: (category: SelectedCategory) => void;
subCategoryCreatePosition?: "top" | "bottom";
subCategoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
}
let {
info,
onSubCategoryClick,
onSubCategoryCreateClick,
onSubCategoryMenuClick,
subCategoryCreatePosition = "bottom",
subCategoryMenuIcon,
...props
}: 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", props.class]}>
{#snippet subCategoryCreate()}
<EntryButton onclick={onSubCategoryCreateClick}>
<div class="flex h-8 items-center gap-x-4">
<IconAddCircle class="text-lg text-gray-600" />
<p class="font-medium text-gray-700">카테고리 추가하기</p>
</div>
</EntryButton>
{/snippet}
{#if subCategoryCreatePosition === "top"}
{@render subCategoryCreate()}
{/if}
{#key info}
<Categories
categories={subCategories}
categoryMenuIcon={subCategoryMenuIcon}
onCategoryClick={onSubCategoryClick}
onCategoryMenuClick={onSubCategoryMenuClick}
/>
{/key}
{#if subCategoryCreatePosition === "bottom"}
{@render subCategoryCreate()}
{/if}
</div>

View File

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

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileInfo } from "$lib/modules/filesystem";
import type { SelectedFile } from "./service";
import IconDraft from "~icons/material-symbols/draft";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: Writable<FileInfo | null>;
onclick: (selectedFile: SelectedFile) => void;
onRemoveClick?: (selectedFile: SelectedFile) => void;
}
let { info, onclick, onRemoveClick }: Props = $props();
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
setTimeout(() => {
onclick({ id, dataKey, dataKeyVersion, name });
}, 100);
};
const removeFile = (e: Event) => {
e.stopPropagation();
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
setTimeout(() => {
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
}, 100);
};
</script>
{#if $info}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div id="button" onclick={openFile} class="h-12 rounded-xl">
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
<div class="flex-shrink-0 text-lg text-blue-400">
<IconDraft />
</div>
<p title={$info.name} class="flex-grow truncate font-medium">
{$info.name}
</p>
{#if onRemoveClick}
<button
id="remove-file"
onclick={removeFile}
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
>
<IconClose class="text-lg" />
</button>
{/if}
</div>
</div>
{/if}
<style>
#button:active:not(:has(#remove-file:active)) {
@apply bg-gray-100;
}
#button-content:active:not(:has(#remove-file:active)) {
@apply scale-95;
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { Modal } from "$lib/components";
import { Button } from "$lib/components/buttons";
import { TextInput } from "$lib/components/inputs";
interface Props {
onCreateClick: (name: string) => void;
isOpen: boolean;
}
let { onCreateClick, isOpen = $bindable() }: Props = $props();
let name = $state("");
const closeModal = () => {
name = "";
isOpen = false;
};
</script>
<Modal bind:isOpen onclose={closeModal}>
<p class="text-xl font-bold">새 카테고리</p>
<div class="mt-2 flex w-full">
<TextInput bind:value={name} placeholder="카테고리 이름" />
</div>
<div class="mt-7 flex gap-2">
<Button color="gray" onclick={closeModal}>닫기</Button>
<Button onclick={() => onCreateClick(name)}>만들기</Button>
</div>
</Modal>

View File

@@ -0,0 +1,147 @@
import { IntegrityError } from "./error";
import db from "./kysely";
import type { Ciphertext } from "./schema";
export type CategoryId = "root" | number;
interface Category {
id: number;
parentId: CategoryId;
userId: number;
mekVersion: number;
encDek: string;
dekVersion: Date;
encName: Ciphertext;
}
export type NewCategory = Omit<Category, "id">;
export const registerCategory = async (params: NewCategory) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (mek?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
const { categoryId } = await trx
.insertInto("category")
.values({
parent_id: params.parentId !== "root" ? params.parentId : null,
user_id: params.userId,
master_encryption_key_version: params.mekVersion,
encrypted_data_encryption_key: params.encDek,
data_encryption_key_version: params.dekVersion,
encrypted_name: params.encName,
})
.returning("id as categoryId")
.executeTakeFirstOrThrow();
await trx
.insertInto("category_log")
.values({
category_id: categoryId,
timestamp: new Date(),
action: "create",
new_name: params.encName,
})
.execute();
});
};
export const getAllCategoriesByParent = async (userId: number, parentId: CategoryId) => {
let query = db.selectFrom("category").selectAll().where("user_id", "=", userId);
query =
parentId === "root"
? query.where("parent_id", "is", null)
: query.where("parent_id", "=", parentId);
const categories = await query.execute();
return categories.map(
(category) =>
({
id: category.id,
parentId: category.parent_id ?? "root",
userId: category.user_id,
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version,
encName: category.encrypted_name,
}) satisfies Category,
);
};
export const getCategory = async (userId: number, categoryId: number) => {
const category = await db
.selectFrom("category")
.selectAll()
.where("id", "=", categoryId)
.where("user_id", "=", userId)
.limit(1)
.executeTakeFirst();
return category
? ({
id: category.id,
parentId: category.parent_id ?? "root",
userId: category.user_id,
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version,
encName: category.encrypted_name,
} satisfies Category)
: null;
};
export const setCategoryEncName = async (
userId: number,
categoryId: number,
dekVersion: Date,
encName: Ciphertext,
) => {
await db.transaction().execute(async (trx) => {
const category = await trx
.selectFrom("category")
.select("data_encryption_key_version")
.where("id", "=", categoryId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!category) {
throw new IntegrityError("Category not found");
} else if (category.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
}
await trx
.updateTable("category")
.set({ encrypted_name: encName })
.where("id", "=", categoryId)
.where("user_id", "=", userId)
.execute();
await trx
.insertInto("category_log")
.values({
category_id: categoryId,
timestamp: new Date(),
action: "rename",
new_name: encName,
})
.execute();
});
};
export const unregisterCategory = async (userId: number, categoryId: number) => {
const res = await db
.deleteFrom("category")
.where("id", "=", categoryId)
.where("user_id", "=", userId)
.executeTakeFirst();
if (res.numDeletedRows === 0n) {
throw new IntegrityError("Category not found");
}
};

View File

@@ -1,4 +1,6 @@
type IntegrityErrorMessages =
// Category
| "Category not found"
// Challenge
| "Challenge already registered"
// Client
@@ -7,6 +9,8 @@ type IntegrityErrorMessages =
// File
| "Directory not found"
| "File not found"
| "File not found in category"
| "File already added to category"
| "Invalid DEK version"
// HSK
| "HSK already registered"

View File

@@ -1,8 +1,10 @@
import { sql, type NotNull } from "kysely";
import pg from "pg";
import { IntegrityError } from "./error";
import db from "./kysely";
import type { Ciphertext } from "./schema";
type DirectoryId = "root" | number;
export type DirectoryId = "root" | number;
interface Directory {
id: number;
@@ -289,6 +291,46 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectoryId)
);
};
export const getAllFilesByCategory = async (
userId: number,
categoryId: number,
recurse: boolean,
) => {
const files = await db
.withRecursive("cte", (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"))
.where("id", "=", categoryId)
.$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")),
),
),
)
.selectFrom("cte")
.select(["file_id", "depth"])
.distinctOn("file_id")
.where("user_id", "=", userId)
.where("file_id", "is not", null)
.$narrowType<{ file_id: NotNull }>()
.orderBy(["file_id", "depth"])
.execute();
return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 }));
};
export const getAllFileIdsByContentHmac = async (
userId: number,
hskVersion: number,
@@ -384,3 +426,60 @@ export const unregisterFile = async (userId: number, fileId: number) => {
}
return { path: file.path };
};
export const addFileToCategory = async (fileId: number, categoryId: number) => {
await db.transaction().execute(async (trx) => {
try {
await trx
.insertInto("file_category")
.values({ file_id: fileId, category_id: categoryId })
.execute();
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: "add-to-category",
category_id: categoryId,
})
.execute();
} catch (e) {
if (e instanceof pg.DatabaseError && e.code === "23505") {
throw new IntegrityError("File already added to category");
}
throw e;
}
});
};
export const getAllFileCategories = async (fileId: number) => {
const categories = await db
.selectFrom("file_category")
.select("category_id")
.where("file_id", "=", fileId)
.execute();
return categories.map(({ category_id }) => ({ id: category_id }));
};
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {
await db.transaction().execute(async (trx) => {
const res = await trx
.deleteFrom("file_category")
.where("file_id", "=", fileId)
.where("category_id", "=", categoryId)
.executeTakeFirst();
if (res.numDeletedRows === 0n) {
throw new IntegrityError("File not found in category");
}
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: "remove-from-category",
category_id: categoryId,
})
.execute();
});
};

View File

@@ -0,0 +1,65 @@
import { Kysely } from "kysely";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const up = async (db: Kysely<any>) => {
// category.ts
await db.schema
.createTable("category")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
.addColumn("parent_id", "integer", (col) => col.references("category.id").onDelete("cascade"))
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("master_encryption_key_version", "integer", (col) => col.notNull())
.addColumn("encrypted_data_encryption_key", "text", (col) => col.unique().notNull())
.addColumn("data_encryption_key_version", "timestamp(3)", (col) => col.notNull())
.addColumn("encrypted_name", "json", (col) => col.notNull())
.addForeignKeyConstraint(
"category_fk01",
["user_id", "master_encryption_key_version"],
"master_encryption_key",
["user_id", "version"],
)
.execute();
await db.schema
.createTable("category_log")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
.addColumn("category_id", "integer", (col) =>
col.references("category.id").onDelete("cascade").notNull(),
)
.addColumn("timestamp", "timestamp(3)", (col) => col.notNull())
.addColumn("action", "text", (col) => col.notNull())
.addColumn("new_name", "json")
.execute();
// file.ts
await db.schema
.alterTable("file_log")
.addColumn("category_id", "integer", (col) =>
col.references("category.id").onDelete("set null"),
)
.execute();
await db.schema
.createTable("file_category")
.addColumn("file_id", "integer", (col) =>
col.references("file.id").onDelete("cascade").notNull(),
)
.addColumn("category_id", "integer", (col) =>
col.references("category.id").onDelete("cascade").notNull(),
)
.addPrimaryKeyConstraint("file_category_pk", ["file_id", "category_id"])
.execute();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const down = async (db: Kysely<any>) => {
await db
.deleteFrom("file_log")
.where((eb) =>
eb.or([eb("action", "=", "add-to-category"), eb("action", "=", "remove-from-category")]),
)
.execute();
await db.schema.dropTable("file_category").execute();
await db.schema.alterTable("file_log").dropColumn("category_id").execute();
await db.schema.dropTable("category_log").execute();
await db.schema.dropTable("category").execute();
};

View File

@@ -1,5 +1,7 @@
import * as Initial1737357000 from "./1737357000-Initial";
import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory";
export default {
"1737357000-Initial": Initial1737357000,
"1737422340-AddFileCategory": AddFileCategory1737422340,
};

View File

@@ -0,0 +1,27 @@
import type { Generated } from "kysely";
import type { Ciphertext } from "./util";
interface CategoryTable {
id: Generated<number>;
parent_id: number | null;
user_id: number;
master_encryption_key_version: number;
encrypted_data_encryption_key: string; // Base64
data_encryption_key_version: Date;
encrypted_name: Ciphertext;
}
interface CategoryLogTable {
id: Generated<number>;
category_id: number;
timestamp: Date;
action: "create" | "rename";
new_name: Ciphertext | null;
}
declare module "./index" {
interface Database {
category: CategoryTable;
category_log: CategoryLogTable;
}
}

View File

@@ -1,9 +1,5 @@
import type { ColumnType, Generated } from "kysely";
export type Ciphertext = {
ciphertext: string; // Base64
iv: string; // Base64
};
import type { Ciphertext } from "./util";
interface DirectoryTable {
id: Generated<number>;
@@ -45,8 +41,14 @@ interface FileLogTable {
id: Generated<number>;
file_id: number;
timestamp: ColumnType<Date, Date, never>;
action: "create" | "rename";
action: "create" | "rename" | "add-to-category" | "remove-from-category";
new_name: Ciphertext | null;
category_id: number | null;
}
interface FileCategoryTable {
file_id: number;
category_id: number;
}
declare module "./index" {
@@ -55,5 +57,6 @@ declare module "./index" {
directory_log: DirectoryLogTable;
file: FileTable;
file_log: FileLogTable;
file_category: FileCategoryTable;
}
}

View File

@@ -1,9 +1,11 @@
export * from "./category";
export * from "./client";
export * from "./file";
export * from "./hsk";
export * from "./mek";
export * from "./session";
export * from "./user";
export * from "./util";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface Database {}

View File

@@ -0,0 +1,4 @@
export type Ciphertext = {
ciphertext: string; // Base64
iv: string; // Base64
};

View File

@@ -0,0 +1,55 @@
import { z } from "zod";
export const categoryIdSchema = z.union([z.enum(["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.infer<typeof categoryInfoResponse>;
export const categoryFileAddRequest = z.object({
file: z.number().int().positive(),
});
export type CategoryFileAddRequest = z.infer<typeof categoryFileAddRequest>;
export const categoryFileListResponse = z.object({
files: z.array(
z.object({
file: z.number().int().positive(),
isRecursive: z.boolean(),
}),
),
});
export type CategoryFileListResponse = z.infer<typeof categoryFileListResponse>;
export const categoryFileRemoveRequest = z.object({
file: z.number().int().positive(),
});
export type CategoryFileRemoveRequest = z.infer<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.infer<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.infer<typeof categoryCreateRequest>;

View File

@@ -1,9 +1,11 @@
import { z } from "zod";
export const directoryIdSchema = z.union([z.enum(["root"]), z.number().int().positive()]);
export const directoryInfoResponse = z.object({
metadata: z
.object({
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
parent: directoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
@@ -29,7 +31,7 @@ export const directoryRenameRequest = z.object({
export type DirectoryRenameRequest = z.infer<typeof directoryRenameRequest>;
export const directoryCreateRequest = z.object({
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
parent: directoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),

View File

@@ -1,8 +1,9 @@
import mime from "mime";
import { z } from "zod";
import { directoryIdSchema } from "./directory";
export const fileInfoResponse = z.object({
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
parent: directoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
@@ -17,6 +18,7 @@ export const fileInfoResponse = z.object({
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.infer<typeof fileInfoResponse>;
@@ -39,7 +41,7 @@ export const duplicateFileScanResponse = z.object({
export type DuplicateFileScanResponse = z.infer<typeof duplicateFileScanResponse>;
export const fileUploadRequest = z.object({
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
parent: directoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),

View File

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

View File

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

View File

@@ -8,11 +8,12 @@ import {
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: "root" | number) => {
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");

View File

@@ -13,6 +13,7 @@ import {
getFile,
setFileEncName,
unregisterFile,
getAllFileCategories,
type NewFile,
} from "$lib/server/db/file";
import type { Ciphertext } from "$lib/server/db/schema";
@@ -24,6 +25,7 @@ export const getFileInformation = async (userId: number, fileId: number) => {
error(404, "Invalid file id");
}
const categories = await getAllFileCategories(fileId);
return {
parentId: file.parentId ?? ("root" as const),
mekVersion: file.mekVersion,
@@ -34,6 +36,7 @@ export const getFileInformation = async (userId: number, fileId: number) => {
encName: file.encName,
encCreatedAt: file.encCreatedAt,
encLastModifiedAt: file.encLastModifiedAt,
categories: categories.map(({ id }) => id),
};
};

View File

@@ -0,0 +1,29 @@
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";
export const requestCategoryCreation = async (
name: string,
parentId: "root" | number,
masterKey: MasterKey,
) => {
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
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,
});
};
export const requestFileRemovalFromCategory = async (fileId: number, categoryId: number) => {
const res = await callPostApi<CategoryFileRemoveRequest>(
`/api/category/${categoryId}/file/remove`,
{ file: fileId },
);
return res.ok;
};

View File

@@ -2,15 +2,34 @@
import FileSaver from "file-saver";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { EntryButton } from "$lib/components/buttons";
import {
getFileInfo,
getCategoryInfo,
type FileInfo,
type CategoryInfo,
} from "$lib/modules/filesystem";
import Categories from "$lib/molecules/Categories";
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
import DownloadStatus from "./DownloadStatus.svelte";
import { requestFileDownload } from "./service";
import {
requestFileRemovalFromCategory,
requestFileDownload,
requestFileAdditionToCategory,
} from "./service";
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 isAddToCategoryBottomSheetOpen = $state(false);
const downloadStatus = $derived(
$fileDownloadStatusStore.find((statusStore) => {
@@ -23,14 +42,7 @@
let viewerType: "image" | "video" | undefined = $state();
let fileBlobUrl: string | undefined = $state();
const updateViewer = async (info: FileInfo, buffer: ArrayBuffer) => {
const contentType = info.contentType;
if (contentType.startsWith("image")) {
viewerType = "image";
} else if (contentType.startsWith("video")) {
viewerType = "video";
}
const updateViewer = async (buffer: ArrayBuffer, contentType: string) => {
const fileBlob = new Blob([buffer], { type: contentType });
if (contentType === "image/heic") {
const { default: heic2any } = await import("heic2any");
@@ -44,19 +56,42 @@
return fileBlob;
};
const addToCategory = async (categoryId: number) => {
await requestFileAdditionToCategory(data.id, categoryId);
isAddToCategoryBottomSheetOpen = false;
info = 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
};
$effect(() => {
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
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 (contentType.startsWith("image")) {
viewerType = "image";
} else if (contentType.startsWith("video")) {
viewerType = "video";
}
untrack(() => {
if (!downloadStatus && !isDownloadRequested) {
isDownloadRequested = true;
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => {
const blob = await updateViewer($info, buffer);
const blob = await updateViewer(buffer, contentType);
if (!viewerType) {
FileSaver.saveAs(blob, $info.name);
}
@@ -68,7 +103,9 @@
$effect(() => {
if ($info && $downloadStatus?.status === "decrypted") {
untrack(() => !isDownloadRequested && updateViewer($info, $downloadStatus.result!));
untrack(
() => !isDownloadRequested && updateViewer($downloadStatus.result!, $info.contentType),
);
}
});
@@ -81,27 +118,51 @@
<div class="flex h-full flex-col">
<TopBar title={$info?.name} />
<DownloadStatus status={downloadStatus} />
<div class="flex w-full flex-grow flex-col items-center pb-4">
{#snippet viewerLoading(message: string)}
<div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">{message}</p>
</div>
{/snippet}
<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 && viewerType === "image"}
{#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.name} />
{:else}
{@render viewerLoading("이미지를 불러오고 있어요.")}
{/if}
{:else if viewerType === "video"}
{#if fileBlobUrl}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={fileBlobUrl} controls></video>
{:else}
{@render viewerLoading("비디오를 불러오고 있어요.")}
{/if}
{#if viewerType === "image"}
{#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.name} />
{:else}
{@render viewerLoading("이미지를 불러오고 있어요.")}
{/if}
{:else if viewerType === "video"}
{#if fileBlobUrl}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={fileBlobUrl} controls></video>
{: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)}
/>
<EntryButton onclick={() => (isAddToCategoryBottomSheetOpen = true)}>
<div class="flex h-8 items-center gap-x-4">
<IconAddCircle class="text-lg text-gray-600" />
<p class="font-medium text-gray-700">카테고리에 추가하기</p>
</div>
</EntryButton>
</div>
</div>
</div>
</div>
<AddToCategoryBottomSheet
bind:isOpen={isAddToCategoryBottomSheetOpen}
onAddToCategoryClick={addToCategory}
/>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { BottomSheet } from "$lib/components";
import { Button } from "$lib/components/buttons";
import { BottomDiv } from "$lib/components/divs";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import SubCategories from "$lib/molecules/SubCategories.svelte";
import CreateCategoryModal from "$lib/organisms/CreateCategoryModal.svelte";
import { masterKeyStore } from "$lib/stores";
import { requestCategoryCreation } from "./service";
interface Props {
onAddToCategoryClick: (categoryId: number) => void;
isOpen: boolean;
}
let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props();
let category: Writable<CategoryInfo | null> | undefined = $state();
let isCreateCategoryModalOpen = $state(false);
const createCategory = async (name: string) => {
if (!$category) return; // TODO: Error handling
await requestCategoryCreation(name, $category.id, $masterKeyStore?.get(1)!);
isCreateCategoryModalOpen = false;
category = getCategoryInfo($category.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
};
$effect(() => {
if (isOpen) {
category = getCategoryInfo("root", $masterKeyStore?.get(1)?.key!);
}
});
</script>
<BottomSheet bind:isOpen>
<div class="flex w-full flex-col justify-between">
{#if $category}
<SubCategories
class="h-fit py-4"
info={$category}
onSubCategoryClick={({ id }) =>
(category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))}
onSubCategoryCreateClick={() => (isCreateCategoryModalOpen = true)}
subCategoryCreatePosition="top"
/>
{#if $category.id !== "root"}
<BottomDiv>
<Button onclick={() => onAddToCategoryClick($category.id)}> 카테고리에 추가하기</Button>
</BottomDiv>
{/if}
{/if}
</div>
</BottomSheet>
<CreateCategoryModal bind:isOpen={isCreateCategoryModalOpen} onCreateClick={createCategory} />

View File

@@ -1,4 +1,8 @@
import { callPostApi } from "$lib/hooks";
import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file";
import type { CategoryFileAddRequest } from "$lib/server/schemas";
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
export const requestFileDownload = async (
fileId: number,
@@ -12,3 +16,10 @@ export const requestFileDownload = async (
storeFileCache(fileId, fileBuffer); // Intended
return fileBuffer;
};
export const requestFileAdditionToCategory = async (fileId: number, categoryId: number) => {
const res = await callPostApi<CategoryFileAddRequest>(`/api/category/${categoryId}/file/add`, {
file: fileId,
});
return res.ok;
};

View File

@@ -1,3 +0,0 @@
<div class="flex h-full items-center justify-center p-4">
<p class="text-gray-500">아직 개발 중이에요.</p>
</div>

View File

@@ -0,0 +1,109 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import type { SelectedCategory } from "$lib/molecules/Categories";
import Category from "$lib/organisms/Category";
import CreateCategoryModal from "$lib/organisms/CreateCategoryModal.svelte";
import { masterKeyStore } from "$lib/stores";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
import DeleteCategoryModal from "./DeleteCategoryModal.svelte";
import RenameCategoryModal from "./RenameCategoryModal.svelte";
import {
requestCategoryCreation,
requestFileRemovalFromCategory,
requestCategoryRename,
requestCategoryDeletion,
} from "./service";
let { data } = $props();
let info: Writable<CategoryInfo | null> | undefined = $state();
let selectedSubCategory: SelectedCategory | undefined = $state();
let isFileRecursive = $state(false);
let isCreateCategoryModalOpen = $state(false);
let isSubCategoryMenuBottomSheetOpen = $state(false);
let isRenameCategoryModalOpen = $state(false);
let isDeleteCategoryModalOpen = $state(false);
const createCategory = async (name: string) => {
await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!);
isCreateCategoryModalOpen = false;
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
};
$effect(() => {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
</script>
<svelte:head>
<title>카테고리</title>
</svelte:head>
<div class="flex min-h-full flex-col">
{#if data.id !== "root"}
<TopBar title={$info?.name} xPadding />
{/if}
<div class="flex-grow bg-gray-100 pb-[5.5em]">
{#if $info}
<Category
bind:isFileRecursive
info={$info}
onFileClick={({ id }) => goto(`/file/${id}`)}
onFileRemoveClick={async ({ id }) => {
await requestFileRemovalFromCategory(id, data.id as number);
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}}
onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
onSubCategoryCreateClick={() => (isCreateCategoryModalOpen = true)}
onSubCategoryMenuClick={(subCategory) => {
selectedSubCategory = subCategory;
isSubCategoryMenuBottomSheetOpen = true;
}}
/>
{/if}
</div>
</div>
<CreateCategoryModal bind:isOpen={isCreateCategoryModalOpen} onCreateClick={createCategory} />
<CategoryMenuBottomSheet
bind:isOpen={isSubCategoryMenuBottomSheetOpen}
bind:selectedCategory={selectedSubCategory}
onRenameClick={() => {
isSubCategoryMenuBottomSheetOpen = false;
isRenameCategoryModalOpen = true;
}}
onDeleteClick={() => {
isSubCategoryMenuBottomSheetOpen = false;
isDeleteCategoryModalOpen = true;
}}
/>
<RenameCategoryModal
bind:isOpen={isRenameCategoryModalOpen}
bind:selectedCategory={selectedSubCategory}
onRenameClick={async (newName) => {
if (selectedSubCategory) {
await requestCategoryRename(selectedSubCategory, newName);
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<DeleteCategoryModal
bind:isOpen={isDeleteCategoryModalOpen}
bind:selectedCategory={selectedSubCategory}
onDeleteClick={async () => {
if (selectedSubCategory) {
await requestCategoryDeletion(selectedSubCategory);
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>

View File

@@ -0,0 +1,17 @@
import { error } from "@sveltejs/kit";
import { z } from "zod";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ params }) => {
const zodRes = z
.object({
id: z.coerce.number().int().positive().optional(),
})
.safeParse(params);
if (!zodRes.success) error(404, "Not found");
const { id } = zodRes.data;
return {
id: id ? id : ("root" as const),
};
};

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { BottomSheet } from "$lib/components";
import { EntryButton } from "$lib/components/buttons";
import type { SelectedCategory } from "$lib/molecules/Categories";
import IconCategory from "~icons/material-symbols/category";
import IconEdit from "~icons/material-symbols/edit";
import IconDelete from "~icons/material-symbols/delete";
interface Props {
onRenameClick: () => void;
onDeleteClick: () => void;
isOpen: boolean;
selectedCategory: SelectedCategory | undefined;
}
let {
onRenameClick,
onDeleteClick,
isOpen = $bindable(),
selectedCategory = $bindable(),
}: Props = $props();
const closeBottomSheet = () => {
isOpen = false;
selectedCategory = undefined;
};
</script>
<BottomSheet bind:isOpen onclose={closeBottomSheet}>
<div class="w-full py-4">
{#if selectedCategory}
{@const { name } = selectedCategory}
<div class="flex h-12 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg">
<IconCategory />
</div>
<p title={name} class="flex-grow truncate font-semibold">
{name}
</p>
</div>
<div class="my-2 h-px w-full bg-gray-200"></div>
{/if}
<EntryButton onclick={onRenameClick}>
<div class="flex h-8 items-center gap-x-4">
<IconEdit class="text-lg" />
<p class="font-medium">이름 바꾸기</p>
</div>
</EntryButton>
<EntryButton onclick={onDeleteClick}>
<div class="flex h-8 items-center gap-x-4 text-red-500">
<IconDelete class="text-lg" />
<p class="font-medium">삭제하기</p>
</div>
</EntryButton>
</div>
</BottomSheet>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { Modal } from "$lib/components";
import { Button } from "$lib/components/buttons";
import type { SelectedCategory } from "$lib/molecules/Categories";
interface Props {
onDeleteClick: () => Promise<boolean>;
isOpen: boolean;
selectedCategory: SelectedCategory | undefined;
}
let { onDeleteClick, isOpen = $bindable(), selectedCategory = $bindable() }: Props = $props();
const closeModal = () => {
isOpen = false;
selectedCategory = undefined;
};
const deleteEntry = async () => {
// TODO: Validation
if (await onDeleteClick()) {
closeModal();
}
};
</script>
<Modal bind:isOpen onclose={closeModal}>
{#if selectedCategory}
{@const { name } = selectedCategory}
{@const nameShort = name.length > 20 ? `${name.slice(0, 20)}...` : name}
<div class="space-y-4">
<div class="space-y-2 break-keep">
<p class="text-xl font-bold">
'{nameShort}' 카테고리를 삭제할까요?
</p>
<p>
모든 하위 카테고리도 함께 삭제돼요. <br />
하지만 카테고리에 추가된 파일들은 삭제되지 않아요.
</p>
</div>
<div class="flex gap-2">
<Button color="gray" onclick={closeModal}>아니요</Button>
<Button onclick={deleteEntry}>삭제할게요</Button>
</div>
</div>
{/if}
</Modal>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import { Modal } from "$lib/components";
import { Button } from "$lib/components/buttons";
import { TextInput } from "$lib/components/inputs";
import type { SelectedCategory } from "$lib/molecules/Categories";
interface Props {
onRenameClick: (newName: string) => Promise<boolean>;
isOpen: boolean;
selectedCategory: SelectedCategory | undefined;
}
let { onRenameClick, isOpen = $bindable(), selectedCategory = $bindable() }: Props = $props();
let name = $state("");
const closeModal = () => {
name = "";
isOpen = false;
selectedCategory = undefined;
};
const renameEntry = async () => {
// TODO: Validation
if (await onRenameClick(name)) {
closeModal();
}
};
$effect(() => {
if (selectedCategory) {
name = selectedCategory.name;
}
});
</script>
<Modal bind:isOpen onclose={closeModal}>
<p class="text-xl font-bold">이름 바꾸기</p>
<div class="mt-2 flex w-full">
<TextInput bind:value={name} placeholder="이름" />
</div>
<div class="mt-7 flex gap-2">
<Button color="gray" onclick={closeModal}>닫기</Button>
<Button onclick={renameEntry}>바꾸기</Button>
</div>
</Modal>

View File

@@ -0,0 +1,22 @@
import { callPostApi } from "$lib/hooks";
import { encryptString } from "$lib/modules/crypto";
import type { SelectedCategory } from "$lib/molecules/Categories";
import type { CategoryRenameRequest } from "$lib/server/schemas";
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
export const requestCategoryRename = async (category: SelectedCategory, newName: string) => {
const newNameEncrypted = await encryptString(newName, category.dataKey);
const res = await callPostApi<CategoryRenameRequest>(`/api/category/${category.id}/rename`, {
dekVersion: category.dataKeyVersion.toISOString(),
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
return res.ok;
};
export const requestCategoryDeletion = async (category: SelectedCategory) => {
const res = await callPostApi(`/api/category/${category.id}/delete`);
return res.ok;
};

View File

@@ -48,14 +48,7 @@
</p>
</div>
<div class="flex gap-2">
<Button
color="gray"
onclick={() => {
isOpen = false;
}}
>
아니요
</Button>
<Button color="gray" onclick={closeModal}>아니요</Button>
<Button onclick={deleteEntry}>삭제할게요</Button>
</div>
</div>

View File

@@ -7,6 +7,7 @@
type DirectoryInfo,
type FileInfo,
} from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import {
fileUploadStatusStore,
isFileUploading,
@@ -15,7 +16,6 @@
} from "$lib/stores";
import File from "./File.svelte";
import SubDirectory from "./SubDirectory.svelte";
import { SortBy, sortEntries } from "./service";
import UploadingFile from "./UploadingFile.svelte";
import type { SelectedDirectoryEntry } from "../service";

View File

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

View File

@@ -1,31 +0,0 @@
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 = SortBy.NAME_ASC,
) => {
let sortFunc: SortFunc;
if (sortBy === SortBy.NAME_ASC) {
sortFunc = sortByNameAsc;
} else {
sortFunc = sortByNameDesc;
}
entries.sort((a, b) => sortFunc(a.name, b.name));
};

View File

@@ -0,0 +1,33 @@
import { error, json } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { categoryInfoResponse, type CategoryInfoResponse } from "$lib/server/schemas";
import { getCategoryInformation } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = z
.object({
id: z.union([z.enum(["root"]), z.coerce.number().int().positive()]),
})
.safeParse(params);
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
const { metadata, categories } = await getCategoryInformation(userId, id);
return json(
categoryInfoResponse.parse({
metadata: metadata && {
parent: metadata.parentId,
mekVersion: metadata.mekVersion,
dek: metadata.encDek,
dekVersion: metadata.dekVersion.toISOString(),
name: metadata.encName.ciphertext,
nameIv: metadata.encName.iv,
},
subCategories: categories,
} satisfies CategoryInfoResponse),
);
};

View File

@@ -0,0 +1,20 @@
import { error, text } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { deleteCategory } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
await deleteCategory(userId, id);
return text("Category deleted", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -0,0 +1,25 @@
import { error, text } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { categoryFileAddRequest } from "$lib/server/schemas";
import { addCategoryFile } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, params, request }) => {
const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!paramsZodRes.success) error(400, "Invalid path parameters");
const { id } = paramsZodRes.data;
const bodyZodRes = categoryFileAddRequest.safeParse(await request.json());
if (!bodyZodRes.success) error(400, "Invalid request body");
const { file } = bodyZodRes.data;
await addCategoryFile(userId, id, file);
return text("File added", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -0,0 +1,36 @@
import { error, json } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { categoryFileListResponse, type CategoryFileListResponse } from "$lib/server/schemas";
import { getCategoryFiles } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals, url, params }) => {
const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!paramsZodRes.success) error(400, "Invalid path parameters");
const { id } = paramsZodRes.data;
const queryZodRes = z
.object({
recurse: z
.enum(["true", "false"])
.transform((value) => value === "true")
.nullable(),
})
.safeParse({ recurse: url.searchParams.get("recurse") });
if (!queryZodRes.success) error(400, "Invalid query parameters");
const { recurse } = queryZodRes.data;
const { files } = await getCategoryFiles(userId, id, recurse ?? false);
return json(
categoryFileListResponse.parse({
files: files.map(({ id, isRecursive }) => ({ file: id, isRecursive })),
}) satisfies CategoryFileListResponse,
);
};

View File

@@ -0,0 +1,25 @@
import { error, text } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { categoryFileRemoveRequest } from "$lib/server/schemas";
import { removeCategoryFile } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, params, request }) => {
const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!paramsZodRes.success) error(400, "Invalid path parameters");
const { id } = paramsZodRes.data;
const bodyZodRes = categoryFileRemoveRequest.safeParse(await request.json());
if (!bodyZodRes.success) error(400, "Invalid request body");
const { file } = bodyZodRes.data;
await removeCategoryFile(userId, id, file);
return text("File removed", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -0,0 +1,25 @@
import { error, text } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { categoryRenameRequest } from "$lib/server/schemas";
import { renameCategory } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, params, request }) => {
const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!paramsZodRes.success) error(400, "Invalid path parameters");
const { id } = paramsZodRes.data;
const bodyZodRes = categoryRenameRequest.safeParse(await request.json());
if (!bodyZodRes.success) error(400, "Invalid request body");
const { dekVersion, name, nameIv } = bodyZodRes.data;
await renameCategory(userId, id, new Date(dekVersion), { ciphertext: name, iv: nameIv });
return text("Category renamed", { headers: { "Content-Type": "text/plain" } });
};

View File

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

View File

@@ -26,6 +26,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
encName,
encCreatedAt,
encLastModifiedAt,
categories,
} = await getFileInformation(userId, id);
return json(
fileInfoResponse.parse({
@@ -41,6 +42,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
createdAtIv: encCreatedAt?.iv,
lastModifiedAt: encLastModifiedAt.ciphertext,
lastModifiedAtIv: encLastModifiedAt.iv,
categories,
} satisfies FileInfoResponse),
);
};