diff --git a/.prettierignore b/.prettierignore index 0f54b15..0c65201 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,8 +3,5 @@ package-lock.json pnpm-lock.yaml yarn.lock -# Output -/drizzle - # Documents *.md diff --git a/package.json b/package.json index 8185f79..9e31c9d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5092a35..9ed4442 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/lib/components/BottomSheet.svelte b/src/lib/components/BottomSheet.svelte index 3d3fbd6..a283957 100644 --- a/src/lib/components/BottomSheet.svelte +++ b/src/lib/components/BottomSheet.svelte @@ -28,7 +28,7 @@
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?.()} diff --git a/src/lib/components/TopBar.svelte b/src/lib/components/TopBar.svelte index 6691feb..9d1893f 100644 --- a/src/lib/components/TopBar.svelte +++ b/src/lib/components/TopBar.svelte @@ -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); }); -
+
diff --git a/src/lib/components/inputs/CheckBox.svelte b/src/lib/components/inputs/CheckBox.svelte new file mode 100644 index 0000000..6875040 --- /dev/null +++ b/src/lib/components/inputs/CheckBox.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/lib/components/inputs/index.ts b/src/lib/components/inputs/index.ts index c2c534d..47cb929 100644 --- a/src/lib/components/inputs/index.ts +++ b/src/lib/components/inputs/index.ts @@ -1 +1,2 @@ +export { default as CheckBox } from "./CheckBox.svelte"; export { default as TextInput } from "./TextInput.svelte"; diff --git a/src/lib/indexedDB/filesystem.ts b/src/lib/indexedDB/filesystem.ts index 5c9fc4d..293c16d 100644 --- a/src/lib/indexedDB/filesystem.ts +++ b/src/lib/indexedDB/filesystem.ts @@ -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; file: EntityTable; + category: EntityTable; }; -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(), ]); }; diff --git a/src/lib/modules/filesystem.ts b/src/lib/modules/filesystem.ts index 1a1ff0f..eaf1d1a 100644 --- a/src/lib/modules/filesystem.ts +++ b/src/lib/modules/filesystem.ts @@ -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>(); const fileInfoStore = new Map>(); +const categoryInfoStore = new Map>(); 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, +) => { + 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, + 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, + 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; }; diff --git a/src/lib/modules/util.ts b/src/lib/modules/util.ts index 67e1b3b..0048e9e 100644 --- a/src/lib/modules/util.ts +++ b/src/lib/modules/util.ts @@ -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 = (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)); +}; diff --git a/src/lib/molecules/Categories/Categories.svelte b/src/lib/molecules/Categories/Categories.svelte new file mode 100644 index 0000000..a11313e --- /dev/null +++ b/src/lib/molecules/Categories/Categories.svelte @@ -0,0 +1,63 @@ + + +{#if categoriesWithName.length > 0} +
+ {#each categoriesWithName as { info }} + + {/each} +
+{/if} diff --git a/src/lib/molecules/Categories/Category.svelte b/src/lib/molecules/Categories/Category.svelte new file mode 100644 index 0000000..ea2c392 --- /dev/null +++ b/src/lib/molecules/Categories/Category.svelte @@ -0,0 +1,71 @@ + + +{#if $info} + + +
+
+
+ +
+

+ {$info.name} +

+ {#if MenuIcon && onMenuClick} + + {/if} +
+
+{/if} + + diff --git a/src/lib/molecules/Categories/index.ts b/src/lib/molecules/Categories/index.ts new file mode 100644 index 0000000..d8a70c2 --- /dev/null +++ b/src/lib/molecules/Categories/index.ts @@ -0,0 +1,2 @@ +export { default } from "./Categories.svelte"; +export * from "./service"; diff --git a/src/lib/molecules/Categories/service.ts b/src/lib/molecules/Categories/service.ts new file mode 100644 index 0000000..08c41db --- /dev/null +++ b/src/lib/molecules/Categories/service.ts @@ -0,0 +1,6 @@ +export interface SelectedCategory { + id: number; + dataKey: CryptoKey; + dataKeyVersion: Date; + name: string; +} diff --git a/src/lib/molecules/SubCategories.svelte b/src/lib/molecules/SubCategories.svelte new file mode 100644 index 0000000..0c16b74 --- /dev/null +++ b/src/lib/molecules/SubCategories.svelte @@ -0,0 +1,65 @@ + + +
+ {#snippet subCategoryCreate()} + +
+ +

카테고리 추가하기

+
+
+ {/snippet} + + {#if subCategoryCreatePosition === "top"} + {@render subCategoryCreate()} + {/if} + {#key info} + + {/key} + {#if subCategoryCreatePosition === "bottom"} + {@render subCategoryCreate()} + {/if} +
diff --git a/src/lib/organisms/Category/Category.svelte b/src/lib/organisms/Category/Category.svelte new file mode 100644 index 0000000..200ac35 --- /dev/null +++ b/src/lib/organisms/Category/Category.svelte @@ -0,0 +1,108 @@ + + +
+
+ {#if info.id !== "root"} +

하위 카테고리

+ {/if} + +
+ {#if info.id !== "root"} +
+
+

파일

+ +

하위 카테고리의 파일

+
+
+
+ {#key info} + {#each files as { info, isRecursive }} + + {:else} +

이 카테고리에 추가된 파일이 없어요.

+ {/each} + {/key} +
+
+ {/if} +
diff --git a/src/lib/organisms/Category/File.svelte b/src/lib/organisms/Category/File.svelte new file mode 100644 index 0000000..e611b0b --- /dev/null +++ b/src/lib/organisms/Category/File.svelte @@ -0,0 +1,69 @@ + + +{#if $info} + + +
+
+
+ +
+

+ {$info.name} +

+ {#if onRemoveClick} + + {/if} +
+
+{/if} + + diff --git a/src/lib/organisms/Category/index.ts b/src/lib/organisms/Category/index.ts new file mode 100644 index 0000000..51e0a58 --- /dev/null +++ b/src/lib/organisms/Category/index.ts @@ -0,0 +1,2 @@ +export { default } from "./Category.svelte"; +export * from "./service"; diff --git a/src/lib/organisms/Category/service.ts b/src/lib/organisms/Category/service.ts new file mode 100644 index 0000000..1d587b5 --- /dev/null +++ b/src/lib/organisms/Category/service.ts @@ -0,0 +1,6 @@ +export interface SelectedFile { + id: number; + dataKey: CryptoKey; + dataKeyVersion: Date; + name: string; +} diff --git a/src/lib/organisms/CreateCategoryModal.svelte b/src/lib/organisms/CreateCategoryModal.svelte new file mode 100644 index 0000000..37f868a --- /dev/null +++ b/src/lib/organisms/CreateCategoryModal.svelte @@ -0,0 +1,30 @@ + + + +

새 카테고리

+
+ +
+
+ + +
+
diff --git a/src/lib/server/db/category.ts b/src/lib/server/db/category.ts new file mode 100644 index 0000000..f5c22ff --- /dev/null +++ b/src/lib/server/db/category.ts @@ -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; + +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"); + } +}; diff --git a/src/lib/server/db/error.ts b/src/lib/server/db/error.ts index 547cc6c..a145f14 100644 --- a/src/lib/server/db/error.ts +++ b/src/lib/server/db/error.ts @@ -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" diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index a893b7d..219dc42 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -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`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`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(); + }); +}; diff --git a/src/lib/server/db/migrations/1737422340-AddFileCategory.ts b/src/lib/server/db/migrations/1737422340-AddFileCategory.ts new file mode 100644 index 0000000..ff811e1 --- /dev/null +++ b/src/lib/server/db/migrations/1737422340-AddFileCategory.ts @@ -0,0 +1,65 @@ +import { Kysely } from "kysely"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const up = async (db: Kysely) => { + // 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) => { + 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(); +}; diff --git a/src/lib/server/db/migrations/index.ts b/src/lib/server/db/migrations/index.ts index 6caca84..aa6ee13 100644 --- a/src/lib/server/db/migrations/index.ts +++ b/src/lib/server/db/migrations/index.ts @@ -1,5 +1,7 @@ import * as Initial1737357000 from "./1737357000-Initial"; +import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory"; export default { "1737357000-Initial": Initial1737357000, + "1737422340-AddFileCategory": AddFileCategory1737422340, }; diff --git a/src/lib/server/db/schema/category.ts b/src/lib/server/db/schema/category.ts new file mode 100644 index 0000000..2304264 --- /dev/null +++ b/src/lib/server/db/schema/category.ts @@ -0,0 +1,27 @@ +import type { Generated } from "kysely"; +import type { Ciphertext } from "./util"; + +interface CategoryTable { + id: Generated; + 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; + category_id: number; + timestamp: Date; + action: "create" | "rename"; + new_name: Ciphertext | null; +} + +declare module "./index" { + interface Database { + category: CategoryTable; + category_log: CategoryLogTable; + } +} diff --git a/src/lib/server/db/schema/file.ts b/src/lib/server/db/schema/file.ts index 6810057..a1bf9bd 100644 --- a/src/lib/server/db/schema/file.ts +++ b/src/lib/server/db/schema/file.ts @@ -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; @@ -45,8 +41,14 @@ interface FileLogTable { id: Generated; file_id: number; timestamp: ColumnType; - 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; } } diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 64aa270..d3dd9b1 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -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 {} diff --git a/src/lib/server/db/schema/util.ts b/src/lib/server/db/schema/util.ts new file mode 100644 index 0000000..d7f7350 --- /dev/null +++ b/src/lib/server/db/schema/util.ts @@ -0,0 +1,4 @@ +export type Ciphertext = { + ciphertext: string; // Base64 + iv: string; // Base64 +}; diff --git a/src/lib/server/schemas/category.ts b/src/lib/server/schemas/category.ts new file mode 100644 index 0000000..a11d525 --- /dev/null +++ b/src/lib/server/schemas/category.ts @@ -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; + +export const categoryFileAddRequest = z.object({ + file: z.number().int().positive(), +}); +export type CategoryFileAddRequest = z.infer; + +export const categoryFileListResponse = z.object({ + files: z.array( + z.object({ + file: z.number().int().positive(), + isRecursive: z.boolean(), + }), + ), +}); +export type CategoryFileListResponse = z.infer; + +export const categoryFileRemoveRequest = z.object({ + file: z.number().int().positive(), +}); +export type CategoryFileRemoveRequest = z.infer; + +export const categoryRenameRequest = z.object({ + dekVersion: z.string().datetime(), + name: z.string().base64().nonempty(), + nameIv: z.string().base64().nonempty(), +}); +export type CategoryRenameRequest = z.infer; + +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; diff --git a/src/lib/server/schemas/directory.ts b/src/lib/server/schemas/directory.ts index 15a5886..473d696 100644 --- a/src/lib/server/schemas/directory.ts +++ b/src/lib/server/schemas/directory.ts @@ -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; 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(), diff --git a/src/lib/server/schemas/file.ts b/src/lib/server/schemas/file.ts index 781baf2..ed0af94 100644 --- a/src/lib/server/schemas/file.ts +++ b/src/lib/server/schemas/file.ts @@ -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; @@ -39,7 +41,7 @@ export const duplicateFileScanResponse = z.object({ export type DuplicateFileScanResponse = z.infer; 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(), diff --git a/src/lib/server/schemas/index.ts b/src/lib/server/schemas/index.ts index 6f8270b..1fed0d0 100644 --- a/src/lib/server/schemas/index.ts +++ b/src/lib/server/schemas/index.ts @@ -1,4 +1,5 @@ export * from "./auth"; +export * from "./category"; export * from "./client"; export * from "./directory"; export * from "./file"; diff --git a/src/lib/server/services/category.ts b/src/lib/server/services/category.ts new file mode 100644 index 0000000..cb3db7a --- /dev/null +++ b/src/lib/server/services/category.ts @@ -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; + } +}; diff --git a/src/lib/server/services/directory.ts b/src/lib/server/services/directory.ts index be795b0..2525069 100644 --- a/src/lib/server/services/directory.ts +++ b/src/lib/server/services/directory.ts @@ -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"); diff --git a/src/lib/server/services/file.ts b/src/lib/server/services/file.ts index 0f2d371..519bdfd 100644 --- a/src/lib/server/services/file.ts +++ b/src/lib/server/services/file.ts @@ -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), }; }; diff --git a/src/lib/services/category.ts b/src/lib/services/category.ts new file mode 100644 index 0000000..ab574f5 --- /dev/null +++ b/src/lib/services/category.ts @@ -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("/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( + `/api/category/${categoryId}/file/remove`, + { file: fileId }, + ); + return res.ok; +}; diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 6188520..2a28505 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -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 | undefined = $state(); + let categories: Writable[] = $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 @@
- -
- {#snippet viewerLoading(message: string)} -
-

{message}

-
- {/snippet} +
+ + {#if $info && viewerType} +
+ {#snippet viewerLoading(message: string)} +

{message}

+ {/snippet} - {#if $info && viewerType === "image"} - {#if fileBlobUrl} - {$info.name} - {:else} - {@render viewerLoading("이미지를 불러오고 있어요.")} - {/if} - {:else if viewerType === "video"} - {#if fileBlobUrl} - - - {:else} - {@render viewerLoading("비디오를 불러오고 있어요.")} - {/if} + {#if viewerType === "image"} + {#if fileBlobUrl} + {$info.name} + {:else} + {@render viewerLoading("이미지를 불러오고 있어요.")} + {/if} + {:else if viewerType === "video"} + {#if fileBlobUrl} + + + {:else} + {@render viewerLoading("비디오를 불러오고 있어요.")} + {/if} + {/if} +
{/if} +
+

카테고리

+
+ goto(`/category/${id}`)} + onCategoryMenuClick={({ id }) => removeFromCategory(id)} + /> + (isAddToCategoryBottomSheetOpen = true)}> +
+ +

카테고리에 추가하기

+
+
+
+
+ + diff --git a/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte b/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte new file mode 100644 index 0000000..ce2908f --- /dev/null +++ b/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte @@ -0,0 +1,58 @@ + + + +
+ {#if $category} + + (category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))} + onSubCategoryCreateClick={() => (isCreateCategoryModalOpen = true)} + subCategoryCreatePosition="top" + /> + {#if $category.id !== "root"} + + + + {/if} + {/if} +
+
+ + diff --git a/src/routes/(fullscreen)/file/[id]/service.ts b/src/routes/(fullscreen)/file/[id]/service.ts index fcc5ce7..43f0134 100644 --- a/src/routes/(fullscreen)/file/[id]/service.ts +++ b/src/routes/(fullscreen)/file/[id]/service.ts @@ -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(`/api/category/${categoryId}/file/add`, { + file: fileId, + }); + return res.ok; +}; diff --git a/src/routes/(main)/category/+page.svelte b/src/routes/(main)/category/+page.svelte deleted file mode 100644 index 73d68b7..0000000 --- a/src/routes/(main)/category/+page.svelte +++ /dev/null @@ -1,3 +0,0 @@ -
-

아직 개발 중이에요.

-
diff --git a/src/routes/(main)/category/[[id]]/+page.svelte b/src/routes/(main)/category/[[id]]/+page.svelte new file mode 100644 index 0000000..587390e --- /dev/null +++ b/src/routes/(main)/category/[[id]]/+page.svelte @@ -0,0 +1,109 @@ + + + + 카테고리 + + +
+ {#if data.id !== "root"} + + {/if} +
+ {#if $info} + 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} +
+
+ + + + { + isSubCategoryMenuBottomSheetOpen = false; + isRenameCategoryModalOpen = true; + }} + onDeleteClick={() => { + isSubCategoryMenuBottomSheetOpen = false; + isDeleteCategoryModalOpen = true; + }} +/> + { + if (selectedSubCategory) { + await requestCategoryRename(selectedSubCategory, newName); + info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> + { + if (selectedSubCategory) { + await requestCategoryDeletion(selectedSubCategory); + info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> diff --git a/src/routes/(main)/category/[[id]]/+page.ts b/src/routes/(main)/category/[[id]]/+page.ts new file mode 100644 index 0000000..cfa37f8 --- /dev/null +++ b/src/routes/(main)/category/[[id]]/+page.ts @@ -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), + }; +}; diff --git a/src/routes/(main)/category/[[id]]/CategoryMenuBottomSheet.svelte b/src/routes/(main)/category/[[id]]/CategoryMenuBottomSheet.svelte new file mode 100644 index 0000000..200501e --- /dev/null +++ b/src/routes/(main)/category/[[id]]/CategoryMenuBottomSheet.svelte @@ -0,0 +1,57 @@ + + + +
+ {#if selectedCategory} + {@const { name } = selectedCategory} +
+
+ +
+

+ {name} +

+
+
+ {/if} + +
+ +

이름 바꾸기

+
+
+ +
+ +

삭제하기

+
+
+
+
diff --git a/src/routes/(main)/category/[[id]]/DeleteCategoryModal.svelte b/src/routes/(main)/category/[[id]]/DeleteCategoryModal.svelte new file mode 100644 index 0000000..b60a715 --- /dev/null +++ b/src/routes/(main)/category/[[id]]/DeleteCategoryModal.svelte @@ -0,0 +1,48 @@ + + + + {#if selectedCategory} + {@const { name } = selectedCategory} + {@const nameShort = name.length > 20 ? `${name.slice(0, 20)}...` : name} +
+
+

+ '{nameShort}' 카테고리를 삭제할까요? +

+

+ 모든 하위 카테고리도 함께 삭제돼요.
+ 하지만 카테고리에 추가된 파일들은 삭제되지 않아요. +

+
+
+ + +
+
+ {/if} +
diff --git a/src/routes/(main)/category/[[id]]/RenameCategoryModal.svelte b/src/routes/(main)/category/[[id]]/RenameCategoryModal.svelte new file mode 100644 index 0000000..dbb13c6 --- /dev/null +++ b/src/routes/(main)/category/[[id]]/RenameCategoryModal.svelte @@ -0,0 +1,47 @@ + + + +

이름 바꾸기

+
+ +
+
+ + +
+
diff --git a/src/routes/(main)/category/[[id]]/service.ts b/src/routes/(main)/category/[[id]]/service.ts new file mode 100644 index 0000000..a4ebe57 --- /dev/null +++ b/src/routes/(main)/category/[[id]]/service.ts @@ -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(`/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; +}; diff --git a/src/routes/(main)/directory/[[id]]/DeleteDirectoryEntryModal.svelte b/src/routes/(main)/directory/[[id]]/DeleteDirectoryEntryModal.svelte index 07fb6dd..16dce02 100644 --- a/src/routes/(main)/directory/[[id]]/DeleteDirectoryEntryModal.svelte +++ b/src/routes/(main)/directory/[[id]]/DeleteDirectoryEntryModal.svelte @@ -48,14 +48,7 @@

- +
diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte index e482e38..baa9760 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte @@ -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"; diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/index.ts b/src/routes/(main)/directory/[[id]]/DirectoryEntries/index.ts index 72ab278..075644e 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/index.ts +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/index.ts @@ -1,2 +1 @@ export { default } from "./DirectoryEntries.svelte"; -export * from "./service"; diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts deleted file mode 100644 index b797727..0000000 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts +++ /dev/null @@ -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 = ( - 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)); -}; diff --git a/src/routes/api/category/[id]/+server.ts b/src/routes/api/category/[id]/+server.ts new file mode 100644 index 0000000..4a486fa --- /dev/null +++ b/src/routes/api/category/[id]/+server.ts @@ -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), + ); +}; diff --git a/src/routes/api/category/[id]/delete/+server.ts b/src/routes/api/category/[id]/delete/+server.ts new file mode 100644 index 0000000..cbbe356 --- /dev/null +++ b/src/routes/api/category/[id]/delete/+server.ts @@ -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" } }); +}; diff --git a/src/routes/api/category/[id]/file/add/+server.ts b/src/routes/api/category/[id]/file/add/+server.ts new file mode 100644 index 0000000..2eaf2f2 --- /dev/null +++ b/src/routes/api/category/[id]/file/add/+server.ts @@ -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" } }); +}; diff --git a/src/routes/api/category/[id]/file/list/+server.ts b/src/routes/api/category/[id]/file/list/+server.ts new file mode 100644 index 0000000..c93c963 --- /dev/null +++ b/src/routes/api/category/[id]/file/list/+server.ts @@ -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, + ); +}; diff --git a/src/routes/api/category/[id]/file/remove/+server.ts b/src/routes/api/category/[id]/file/remove/+server.ts new file mode 100644 index 0000000..6fdcccf --- /dev/null +++ b/src/routes/api/category/[id]/file/remove/+server.ts @@ -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" } }); +}; diff --git a/src/routes/api/category/[id]/rename/+server.ts b/src/routes/api/category/[id]/rename/+server.ts new file mode 100644 index 0000000..5351544 --- /dev/null +++ b/src/routes/api/category/[id]/rename/+server.ts @@ -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" } }); +}; diff --git a/src/routes/api/category/create/+server.ts b/src/routes/api/category/create/+server.ts new file mode 100644 index 0000000..216d850 --- /dev/null +++ b/src/routes/api/category/create/+server.ts @@ -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" } }); +}; diff --git a/src/routes/api/file/[id]/+server.ts b/src/routes/api/file/[id]/+server.ts index 892f62b..23e9385 100644 --- a/src/routes/api/file/[id]/+server.ts +++ b/src/routes/api/file/[id]/+server.ts @@ -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), ); };