diff --git a/package.json b/package.json index 3479c2c..c16b700 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "arkvault", "private": true, - "version": "0.6.0", + "version": "0.7.0", "type": "module", "scripts": { "dev": "vite dev", @@ -17,11 +17,12 @@ }, "devDependencies": { "@eslint/compat": "^2.0.0", + "@eslint/js": "^9.39.2", "@iconify-json/material-symbols": "^1.2.50", "@sveltejs/adapter-node": "^5.4.0", "@sveltejs/kit": "^2.49.2", "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@tanstack/svelte-virtual": "^3.13.13", + "@tanstack/svelte-virtual": "^3.13.16", "@trpc/client": "^11.8.1", "@types/file-saver": "^2.0.7", "@types/ms": "^0.7.34", @@ -49,7 +50,7 @@ "svelte-check": "^4.3.5", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", - "typescript-eslint": "^8.50.1", + "typescript-eslint": "^8.51.0", "unplugin-icons": "^22.5.0", "vite": "^7.3.0" }, @@ -63,7 +64,7 @@ "pg": "^8.16.3", "superjson": "^2.2.6", "uuid": "^13.0.0", - "zod": "^4.2.1" + "zod": "^4.3.5" }, "engines": { "node": "^22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9dcf04f..e4e336f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,12 +36,15 @@ importers: specifier: ^13.0.0 version: 13.0.0 zod: - specifier: ^4.2.1 - version: 4.2.1 + specifier: ^4.3.5 + version: 4.3.5 devDependencies: '@eslint/compat': specifier: ^2.0.0 version: 2.0.0(eslint@9.39.2(jiti@1.21.7)) + '@eslint/js': + specifier: ^9.39.2 + version: 9.39.2 '@iconify-json/material-symbols': specifier: ^1.2.50 version: 1.2.50 @@ -55,8 +58,8 @@ importers: specifier: ^6.2.1 version: 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@1.21.7)(yaml@2.8.0)) '@tanstack/svelte-virtual': - specifier: ^3.13.13 - version: 3.13.13(svelte@5.46.1) + specifier: ^3.13.16 + version: 3.13.16(svelte@5.46.1) '@trpc/client': specifier: ^11.8.1 version: 11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3) @@ -139,8 +142,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.50.1 - version: 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.51.0 + version: 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) unplugin-icons: specifier: ^22.5.0 version: 22.5.0(svelte@5.46.1) @@ -316,8 +319,8 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -617,13 +620,13 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || ^7.0.0 - '@tanstack/svelte-virtual@3.13.13': - resolution: {integrity: sha512-VDOvbRw3R+XBQdFodEJ4E7AOmEyo3Bmr4zL4DLVnJ0fxICdbvY5F5t8zSwJ4f7lqjckXi0yKFzY8WBtjaNbsGQ==} + '@tanstack/svelte-virtual@3.13.16': + resolution: {integrity: sha512-LRDPRzAPTIiDjiCA9lhNlFnZRLj/XsNhzNRsT5JEA8hzcBmZw8avdYYVjydPAy0ObFJgG1zBAm9Dtvwqju36sg==} peerDependencies: svelte: ^3.48.0 || ^4.0.0 || ^5.0.0 - '@tanstack/virtual-core@3.13.13': - resolution: {integrity: sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==} + '@tanstack/virtual-core@3.13.16': + resolution: {integrity: sha512-njazUC8mDkrxWmyZmn/3eXrDcP8Msb3chSr4q6a65RmwdSbMlMCdnOphv6/8mLO7O3Fuza5s4M4DclmvAO5w0w==} '@trpc/client@11.8.1': resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==} @@ -663,63 +666,63 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - '@typescript-eslint/eslint-plugin@8.50.1': - resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==} + '@typescript-eslint/eslint-plugin@8.51.0': + resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.50.1 + '@typescript-eslint/parser': ^8.51.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.50.1': - resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==} + '@typescript-eslint/parser@8.51.0': + resolution: {integrity: sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.50.1': - resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==} + '@typescript-eslint/project-service@8.51.0': + resolution: {integrity: sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.50.1': - resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==} + '@typescript-eslint/scope-manager@8.51.0': + resolution: {integrity: sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.50.1': - resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==} + '@typescript-eslint/tsconfig-utils@8.51.0': + resolution: {integrity: sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.50.1': - resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==} + '@typescript-eslint/type-utils@8.51.0': + resolution: {integrity: sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.50.1': - resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==} + '@typescript-eslint/types@8.51.0': + resolution: {integrity: sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.50.1': - resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==} + '@typescript-eslint/typescript-estree@8.51.0': + resolution: {integrity: sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.50.1': - resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==} + '@typescript-eslint/utils@8.51.0': + resolution: {integrity: sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.50.1': - resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==} + '@typescript-eslint/visitor-keys@8.51.0': + resolution: {integrity: sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@xmldom/xmldom@0.9.8': @@ -827,8 +830,8 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001761: - resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + caniuse-lite@1.0.30001762: + resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1039,8 +1042,8 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrap@2.2.1: @@ -1854,8 +1857,8 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -1877,8 +1880,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.50.1: - resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} + typescript-eslint@8.51.0: + resolution: {integrity: sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -2022,8 +2025,8 @@ packages: zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} - zod@4.2.1: - resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + zod@4.3.5: + resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} snapshots: @@ -2114,7 +2117,7 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@1.21.7))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))': dependencies: eslint: 9.39.2(jiti@1.21.7) eslint-visitor-keys: 3.4.3 @@ -2386,12 +2389,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/svelte-virtual@3.13.13(svelte@5.46.1)': + '@tanstack/svelte-virtual@3.13.16(svelte@5.46.1)': dependencies: - '@tanstack/virtual-core': 3.13.13 + '@tanstack/virtual-core': 3.13.16 svelte: 5.46.1 - '@tanstack/virtual-core@3.13.13': {} + '@tanstack/virtual-core@3.13.16': {} '@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3)': dependencies: @@ -2428,95 +2431,95 @@ snapshots: '@types/resolve@1.20.2': {} - '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.50.1 - '@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.51.0 + '@typescript-eslint/type-utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.51.0 eslint: 9.39.2(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.50.1 - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/scope-manager': 8.51.0 + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.51.0 debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.50.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.51.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) - '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3) + '@typescript-eslint/types': 8.51.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.50.1': + '@typescript-eslint/scope-manager@8.51.0': dependencies: - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/visitor-keys': 8.51.0 - '@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.51.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.50.1': {} + '@typescript-eslint/types@8.51.0': {} - '@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.51.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.50.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/project-service': 8.51.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3) + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/visitor-keys': 8.51.0 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.50.1 - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.51.0 + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.50.1': + '@typescript-eslint/visitor-keys@8.51.0': dependencies: - '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/types': 8.51.0 eslint-visitor-keys: 4.2.1 '@xmldom/xmldom@0.9.8': @@ -2564,7 +2567,7 @@ snapshots: autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001761 + caniuse-lite: 1.0.30001762 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.6 @@ -2602,7 +2605,7 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.11 - caniuse-lite: 1.0.30001761 + caniuse-lite: 1.0.30001762 electron-to-chromium: 1.5.267 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -2631,7 +2634,7 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001761: {} + caniuse-lite@1.0.30001762: {} chalk@4.1.2: dependencies: @@ -2795,7 +2798,7 @@ snapshots: eslint-plugin-svelte@3.13.1(eslint@9.39.2(jiti@1.21.7))(svelte@5.46.1): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) '@jridgewell/sourcemap-codec': 1.5.5 eslint: 9.39.2(jiti@1.21.7) esutils: 2.0.3 @@ -2828,7 +2831,7 @@ snapshots: eslint@9.39.2(jiti@1.21.7): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -2848,7 +2851,7 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.6.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -2875,7 +2878,7 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -3601,7 +3604,7 @@ snapshots: totalist@3.0.1: {} - ts-api-utils@2.1.0(typescript@5.9.3): + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3615,12 +3618,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): + typescript-eslint@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: @@ -3704,4 +3707,4 @@ snapshots: zimmerframe@1.1.4: {} - zod@4.2.1: {} + zod@4.3.5: {} diff --git a/src/lib/components/atoms/RowVirtualizer.svelte b/src/lib/components/atoms/RowVirtualizer.svelte new file mode 100644 index 0000000..67a684d --- /dev/null +++ b/src/lib/components/atoms/RowVirtualizer.svelte @@ -0,0 +1,60 @@ + + +
+
+ {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)} +
+ {@render item(virtualItem.index)} +
+ {/each} +
+ {#if placeholder && count === 0} + {@render placeholder()} + {/if} +
diff --git a/src/lib/components/atoms/buttons/FileThumbnailButton.svelte b/src/lib/components/atoms/buttons/FileThumbnailButton.svelte index c18101c..6c5632c 100644 --- a/src/lib/components/atoms/buttons/FileThumbnailButton.svelte +++ b/src/lib/components/atoms/buttons/FileThumbnailButton.svelte @@ -1,42 +1,24 @@ -{#if $info} - -{/if} + diff --git a/src/lib/components/atoms/index.ts b/src/lib/components/atoms/index.ts index 14b0849..61a0238 100644 --- a/src/lib/components/atoms/index.ts +++ b/src/lib/components/atoms/index.ts @@ -3,3 +3,4 @@ export * from "./buttons"; export * from "./divs"; export * from "./inputs"; export { default as Modal } from "./Modal.svelte"; +export { default as RowVirtualizer } from "./RowVirtualizer.svelte"; diff --git a/src/lib/components/molecules/Categories.svelte b/src/lib/components/molecules/Categories.svelte new file mode 100644 index 0000000..72fe7de --- /dev/null +++ b/src/lib/components/molecules/Categories.svelte @@ -0,0 +1,44 @@ + + + + +{#if categoriesWithName.length > 0} +
+ {#each categoriesWithName as category (category.id)} + onCategoryClick(category)} + actionButtonIcon={categoryMenuIcon} + onActionButtonClick={() => onCategoryMenuClick?.(category)} + > + + + {/each} +
+{/if} diff --git a/src/lib/components/molecules/Categories/Categories.svelte b/src/lib/components/molecules/Categories/Categories.svelte deleted file mode 100644 index 54368c6..0000000 --- a/src/lib/components/molecules/Categories/Categories.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - -{#if categoriesWithName.length > 0} -
- {#each categoriesWithName as { info }} - - {/each} -
-{/if} diff --git a/src/lib/components/molecules/Categories/Category.svelte b/src/lib/components/molecules/Categories/Category.svelte deleted file mode 100644 index aab227c..0000000 --- a/src/lib/components/molecules/Categories/Category.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - -{#if $info} - - - -{/if} diff --git a/src/lib/components/molecules/Categories/index.ts b/src/lib/components/molecules/Categories/index.ts deleted file mode 100644 index d8a70c2..0000000 --- a/src/lib/components/molecules/Categories/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./Categories.svelte"; -export * from "./service"; diff --git a/src/lib/components/molecules/Categories/service.ts b/src/lib/components/molecules/Categories/service.ts deleted file mode 100644 index 08c41db..0000000 --- a/src/lib/components/molecules/Categories/service.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface SelectedCategory { - id: number; - dataKey: CryptoKey; - dataKeyVersion: Date; - name: string; -} diff --git a/src/lib/components/molecules/SubCategories.svelte b/src/lib/components/molecules/SubCategories.svelte index 9c84a89..6db38f1 100644 --- a/src/lib/components/molecules/SubCategories.svelte +++ b/src/lib/components/molecules/SubCategories.svelte @@ -1,10 +1,8 @@
@@ -53,14 +43,12 @@ {#if subCategoryCreatePosition === "top"} {@render subCategoryCreate()} {/if} - {#key info} - - {/key} + {#if subCategoryCreatePosition === "bottom"} {@render subCategoryCreate()} {/if} diff --git a/src/lib/components/molecules/index.ts b/src/lib/components/molecules/index.ts index 8edc84a..a36afcd 100644 --- a/src/lib/components/molecules/index.ts +++ b/src/lib/components/molecules/index.ts @@ -1,7 +1,7 @@ export * from "./ActionModal.svelte"; export { default as ActionModal } from "./ActionModal.svelte"; -export * from "./Categories"; -export { default as Categories } from "./Categories"; +export * from "./Categories.svelte"; +export { default as Categories } from "./Categories.svelte"; export { default as IconEntryButton } from "./IconEntryButton.svelte"; export * from "./labels"; export { default as SubCategories } from "./SubCategories.svelte"; diff --git a/src/lib/components/organisms/Category/Category.svelte b/src/lib/components/organisms/Category/Category.svelte deleted file mode 100644 index ce3abcd..0000000 --- a/src/lib/components/organisms/Category/Category.svelte +++ /dev/null @@ -1,107 +0,0 @@ - - -
-
- {#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/components/organisms/Category/File.svelte b/src/lib/components/organisms/Category/File.svelte deleted file mode 100644 index 8e3fc12..0000000 --- a/src/lib/components/organisms/Category/File.svelte +++ /dev/null @@ -1,59 +0,0 @@ - - -{#if $info} - - - -{/if} diff --git a/src/lib/components/organisms/Category/index.ts b/src/lib/components/organisms/Category/index.ts deleted file mode 100644 index 51e0a58..0000000 --- a/src/lib/components/organisms/Category/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./Category.svelte"; -export * from "./service"; diff --git a/src/lib/components/organisms/Category/service.ts b/src/lib/components/organisms/Category/service.ts deleted file mode 100644 index fb6e640..0000000 --- a/src/lib/components/organisms/Category/service.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { requestFileThumbnailDownload } from "$lib/services/file"; - -export interface SelectedFile { - id: number; - dataKey: CryptoKey; - dataKeyVersion: Date; - name: string; -} diff --git a/src/lib/components/organisms/Gallery.svelte b/src/lib/components/organisms/Gallery.svelte deleted file mode 100644 index 8537ac5..0000000 --- a/src/lib/components/organisms/Gallery.svelte +++ /dev/null @@ -1,148 +0,0 @@ - - -
-
- {#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)} - {@const row = rows[virtualRow.index]!} -
- {#if row.type === "header"} -

{row.label}

- {:else} -
- {#each row.items as { info }} - - {/each} -
- {/if} -
- {/each} -
- {#if $virtualizer.getVirtualItems().length === 0} -
-

- {#if files.length === 0} - 업로드된 파일이 없어요. - {:else if filesWithDate.length === 0} - 파일 목록을 불러오고 있어요. - {:else} - 사진 또는 동영상이 없어요. - {/if} -

-
- {/if} -
diff --git a/src/lib/components/organisms/index.ts b/src/lib/components/organisms/index.ts index 9687bfe..fa02317 100644 --- a/src/lib/components/organisms/index.ts +++ b/src/lib/components/organisms/index.ts @@ -1,4 +1 @@ -export * from "./Category"; -export { default as Category } from "./Category"; -export { default as Gallery } from "./Gallery.svelte"; export * from "./modals"; diff --git a/src/lib/indexedDB/filesystem.ts b/src/lib/indexedDB/filesystem.ts index cf60b93..87c0d70 100644 --- a/src/lib/indexedDB/filesystem.ts +++ b/src/lib/indexedDB/filesystem.ts @@ -1,7 +1,5 @@ import { Dexie, type EntityTable } from "dexie"; -export type DirectoryId = "root" | number; - interface DirectoryInfo { id: number; parentId: DirectoryId; @@ -15,17 +13,15 @@ interface FileInfo { contentType: string; createdAt?: Date; lastModifiedAt: Date; - categoryIds: number[]; + categoryIds?: number[]; } -export type CategoryId = "root" | number; - interface CategoryInfo { id: number; parentId: CategoryId; name: string; - files: { id: number; isRecursive: boolean }[]; - isFileRecursive: boolean; + files?: { id: number; isRecursive: boolean }[]; + isFileRecursive?: boolean; } const filesystem = new Dexie("filesystem") as Dexie & { @@ -59,13 +55,23 @@ export const getDirectoryInfo = async (id: number) => { }; export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => { - await filesystem.directory.put(directoryInfo); + await filesystem.directory.upsert(directoryInfo.id, { ...directoryInfo }); }; export const deleteDirectoryInfo = async (id: number) => { await filesystem.directory.delete(id); }; +export const deleteDanglingDirectoryInfos = async ( + parentId: DirectoryId, + validIds: Set, +) => { + await filesystem.directory + .where({ parentId }) + .and((directory) => !validIds.has(directory.id)) + .delete(); +}; + export const getAllFileInfos = async () => { return await filesystem.file.toArray(); }; @@ -78,14 +84,29 @@ export const getFileInfo = async (id: number) => { return await filesystem.file.get(id); }; +export const bulkGetFileInfos = async (ids: number[]) => { + return await filesystem.file.bulkGet(ids); +}; + export const storeFileInfo = async (fileInfo: FileInfo) => { - await filesystem.file.put(fileInfo); + await filesystem.file.upsert(fileInfo.id, { ...fileInfo }); }; export const deleteFileInfo = async (id: number) => { await filesystem.file.delete(id); }; +export const bulkDeleteFileInfos = async (ids: number[]) => { + await filesystem.file.bulkDelete(ids); +}; + +export const deleteDanglingFileInfos = async (parentId: DirectoryId, validIds: Set) => { + await filesystem.file + .where({ parentId }) + .and((file) => !validIds.has(file.id)) + .delete(); +}; + export const getCategoryInfos = async (parentId: CategoryId) => { return await filesystem.category.where({ parentId }).toArray(); }; @@ -95,7 +116,7 @@ export const getCategoryInfo = async (id: number) => { }; export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => { - await filesystem.category.put(categoryInfo); + await filesystem.category.upsert(categoryInfo.id, { ...categoryInfo }); }; export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => { @@ -106,6 +127,13 @@ export const deleteCategoryInfo = async (id: number) => { await filesystem.category.delete(id); }; +export const deleteDanglingCategoryInfos = async (parentId: CategoryId, validIds: Set) => { + await filesystem.category + .where({ parentId }) + .and((category) => !validIds.has(category.id)) + .delete(); +}; + export const cleanupDanglingInfos = async () => { const validDirectoryIds: number[] = []; const validFileIds: number[] = []; diff --git a/src/lib/modules/file/cache.ts b/src/lib/modules/file/cache.ts index ccb187e..fe3c66c 100644 --- a/src/lib/modules/file/cache.ts +++ b/src/lib/modules/file/cache.ts @@ -1,15 +1,12 @@ -import { LRUCache } from "lru-cache"; import { getFileCacheIndex as getFileCacheIndexFromIndexedDB, storeFileCacheIndex, deleteFileCacheIndex, type FileCacheIndex, } from "$lib/indexedDB"; -import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs"; -import { getThumbnailUrl } from "$lib/modules/thumbnail"; +import { readFile, writeFile, deleteFile } from "$lib/modules/opfs"; const fileCacheIndex = new Map(); -const loadedThumbnails = new LRUCache({ max: 100 }); export const prepareFileCache = async () => { for (const cache of await getFileCacheIndexFromIndexedDB()) { @@ -51,30 +48,3 @@ export const deleteFileCache = async (fileId: number) => { await deleteFile(`/cache/${fileId}`); await deleteFileCacheIndex(fileId); }; - -export const getFileThumbnailCache = async (fileId: number) => { - const thumbnail = loadedThumbnails.get(fileId); - if (thumbnail) return thumbnail; - - const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`); - if (!thumbnailBuffer) return null; - - const thumbnailUrl = getThumbnailUrl(thumbnailBuffer); - loadedThumbnails.set(fileId, thumbnailUrl); - return thumbnailUrl; -}; - -export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => { - await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); - loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer)); -}; - -export const deleteFileThumbnailCache = async (fileId: number) => { - loadedThumbnails.delete(fileId); - await deleteFile(`/thumbnail/file/${fileId}`); -}; - -export const deleteAllFileThumbnailCaches = async () => { - loadedThumbnails.clear(); - await deleteDirectory("/thumbnail/file"); -}; diff --git a/src/lib/modules/file/download.svelte.ts b/src/lib/modules/file/download.svelte.ts new file mode 100644 index 0000000..bea8316 --- /dev/null +++ b/src/lib/modules/file/download.svelte.ts @@ -0,0 +1,95 @@ +import axios from "axios"; +import { limitFunction } from "p-limit"; +import { decryptData } from "$lib/modules/crypto"; + +export interface FileDownloadState { + id: number; + status: + | "download-pending" + | "downloading" + | "decryption-pending" + | "decrypting" + | "decrypted" + | "canceled" + | "error"; + progress?: number; + rate?: number; + estimated?: number; + result?: ArrayBuffer; +} + +type LiveFileDownloadState = FileDownloadState & { + status: "download-pending" | "downloading" | "decryption-pending" | "decrypting"; +}; + +let downloadingFiles: FileDownloadState[] = $state([]); + +export const isFileDownloading = ( + status: FileDownloadState["status"], +): status is LiveFileDownloadState["status"] => + ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status); + +export const getFileDownloadState = (fileId: number) => { + return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status)); +}; + +export const getDownloadingFiles = () => { + return downloadingFiles.filter((file) => isFileDownloading(file.status)); +}; + +export const clearDownloadedFiles = () => { + downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status)); +}; + +const requestFileDownload = limitFunction( + async (state: FileDownloadState, id: number) => { + state.status = "downloading"; + + const res = await axios.get(`/api/file/${id}/download`, { + responseType: "arraybuffer", + onDownloadProgress: ({ progress, rate, estimated }) => { + state.progress = progress; + state.rate = rate; + state.estimated = estimated; + }, + }); + const fileEncrypted: ArrayBuffer = res.data; + + state.status = "decryption-pending"; + return fileEncrypted; + }, + { concurrency: 1 }, +); + +const decryptFile = limitFunction( + async ( + state: FileDownloadState, + fileEncrypted: ArrayBuffer, + fileEncryptedIv: string, + dataKey: CryptoKey, + ) => { + state.status = "decrypting"; + + const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey); + + state.status = "decrypted"; + state.result = fileBuffer; + return fileBuffer; + }, + { concurrency: 4 }, +); + +export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => { + downloadingFiles.push({ + id, + status: "download-pending", + }); + const state = downloadingFiles.at(-1)!; + + try { + return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey); + } catch (e) { + state.status = "error"; + throw e; + } +}; diff --git a/src/lib/modules/file/download.ts b/src/lib/modules/file/download.ts deleted file mode 100644 index b0efb30..0000000 --- a/src/lib/modules/file/download.ts +++ /dev/null @@ -1,84 +0,0 @@ -import axios from "axios"; -import { limitFunction } from "p-limit"; -import { writable, type Writable } from "svelte/store"; -import { decryptData } from "$lib/modules/crypto"; -import { fileDownloadStatusStore, type FileDownloadStatus } from "$lib/stores"; - -const requestFileDownload = limitFunction( - async (status: Writable, id: number) => { - status.update((value) => { - value.status = "downloading"; - return value; - }); - - const res = await axios.get(`/api/file/${id}/download`, { - responseType: "arraybuffer", - onDownloadProgress: ({ progress, rate, estimated }) => { - status.update((value) => { - value.progress = progress; - value.rate = rate; - value.estimated = estimated; - return value; - }); - }, - }); - const fileEncrypted: ArrayBuffer = res.data; - - status.update((value) => { - value.status = "decryption-pending"; - return value; - }); - return fileEncrypted; - }, - { concurrency: 1 }, -); - -const decryptFile = limitFunction( - async ( - status: Writable, - fileEncrypted: ArrayBuffer, - fileEncryptedIv: string, - dataKey: CryptoKey, - ) => { - status.update((value) => { - value.status = "decrypting"; - return value; - }); - - const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey); - - status.update((value) => { - value.status = "decrypted"; - value.result = fileBuffer; - return value; - }); - return fileBuffer; - }, - { concurrency: 4 }, -); - -export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => { - const status = writable({ - id, - status: "download-pending", - }); - fileDownloadStatusStore.update((value) => { - value.push(status); - return value; - }); - - try { - return await decryptFile( - status, - await requestFileDownload(status, id), - fileEncryptedIv, - dataKey, - ); - } catch (e) { - status.update((value) => { - value.status = "error"; - return value; - }); - throw e; - } -}; diff --git a/src/lib/modules/file/index.ts b/src/lib/modules/file/index.ts index 42a5613..9e9ce0c 100644 --- a/src/lib/modules/file/index.ts +++ b/src/lib/modules/file/index.ts @@ -1,3 +1,4 @@ export * from "./cache"; -export * from "./download"; -export * from "./upload"; +export * from "./download.svelte"; +export * from "./thumbnail"; +export * from "./upload.svelte"; diff --git a/src/lib/modules/file/thumbnail.ts b/src/lib/modules/file/thumbnail.ts new file mode 100644 index 0000000..f923153 --- /dev/null +++ b/src/lib/modules/file/thumbnail.ts @@ -0,0 +1,90 @@ +import { LRUCache } from "lru-cache"; +import { writable, type Writable } from "svelte/store"; +import { browser } from "$app/environment"; +import { decryptData } from "$lib/modules/crypto"; +import type { SummarizedFileInfo } from "$lib/modules/filesystem"; +import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs"; +import { getThumbnailUrl } from "$lib/modules/thumbnail"; +import { isTRPCClientError, trpc } from "$trpc/client"; + +const loadedThumbnails = new LRUCache>({ max: 100 }); +const loadingThumbnails = new Map>(); + +const fetchFromOpfs = async (fileId: number) => { + const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`); + if (thumbnailBuffer) { + return getThumbnailUrl(thumbnailBuffer); + } +}; + +const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => { + try { + const [thumbnailEncrypted, { contentIv: thumbnailEncryptedIv }] = await Promise.all([ + fetch(`/api/file/${fileId}/thumbnail/download`), + trpc().file.thumbnail.query({ id: fileId }), + ]); + const thumbnailBuffer = await decryptData( + await thumbnailEncrypted.arrayBuffer(), + thumbnailEncryptedIv, + dataKey, + ); + + void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); + return getThumbnailUrl(thumbnailBuffer); + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + return null; + } + throw e; + } +}; + +export const getFileThumbnail = (file: SummarizedFileInfo) => { + if ( + !browser || + !(file.contentType.startsWith("image/") || file.contentType.startsWith("video/")) + ) { + return undefined; + } + + const thumbnail = loadedThumbnails.get(file.id); + if (thumbnail) return thumbnail; + + let loadingThumbnail = loadingThumbnails.get(file.id); + if (loadingThumbnail) return loadingThumbnail; + + loadingThumbnail = writable(undefined); + loadingThumbnails.set(file.id, loadingThumbnail); + + fetchFromOpfs(file.id) + .then((thumbnail) => thumbnail ?? (file.dataKey && fetchFromServer(file.id, file.dataKey.key))) + .then((thumbnail) => { + if (thumbnail) { + loadingThumbnail.set(thumbnail); + loadedThumbnails.set(file.id, loadingThumbnail as Writable); + } + loadingThumbnails.delete(file.id); + }); + return loadingThumbnail; +}; + +export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => { + await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); + + const oldThumbnail = loadedThumbnails.get(fileId); + if (oldThumbnail) { + oldThumbnail.set(getThumbnailUrl(thumbnailBuffer)); + } else { + loadedThumbnails.set(fileId, writable(getThumbnailUrl(thumbnailBuffer))); + } +}; + +export const deleteFileThumbnailCache = async (fileId: number) => { + loadedThumbnails.delete(fileId); + await deleteFile(`/thumbnail/file/${fileId}`); +}; + +export const deleteAllFileThumbnailCaches = async () => { + loadedThumbnails.clear(); + await deleteDirectory(`/thumbnail/file`); +}; diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.svelte.ts similarity index 54% rename from src/lib/modules/file/upload.ts rename to src/lib/modules/file/upload.svelte.ts index 31aabd8..a632eb5 100644 --- a/src/lib/modules/file/upload.ts +++ b/src/lib/modules/file/upload.svelte.ts @@ -1,7 +1,6 @@ import axios from "axios"; import ExifReader from "exifreader"; import { limitFunction } from "p-limit"; -import { writable, type Writable } from "svelte/store"; import { encodeToBase64, generateDataKey, @@ -11,20 +10,56 @@ import { digestMessage, signMessageHmac, } from "$lib/modules/crypto"; +import { Scheduler } from "$lib/modules/scheduler"; import { generateThumbnail } from "$lib/modules/thumbnail"; import type { FileThumbnailUploadRequest, FileUploadRequest, FileUploadResponse, } from "$lib/server/schemas"; -import { - fileUploadStatusStore, - type MasterKey, - type HmacSecret, - type FileUploadStatus, -} from "$lib/stores"; +import type { MasterKey, HmacSecret } from "$lib/stores"; import { trpc } from "$trpc/client"; +export interface FileUploadState { + name: string; + parentId: DirectoryId; + status: + | "queued" + | "encryption-pending" + | "encrypting" + | "upload-pending" + | "uploading" + | "uploaded" + | "canceled" + | "error"; + progress?: number; + rate?: number; + estimated?: number; +} + +export type LiveFileUploadState = FileUploadState & { + status: "queued" | "encryption-pending" | "encrypting" | "upload-pending" | "uploading"; +}; + +const scheduler = new Scheduler< + { fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined +>(); +let uploadingFiles: FileUploadState[] = $state([]); + +const isFileUploading = (status: FileUploadState["status"]) => + ["queued", "encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status); + +export const getUploadingFiles = (parentId?: DirectoryId) => { + return uploadingFiles.filter( + (file) => + (parentId === undefined || file.parentId === parentId) && isFileUploading(file.status), + ); +}; + +export const clearUploadedFiles = () => { + uploadingFiles = uploadingFiles.filter((file) => isFileUploading(file.status)); +}; + const requestDuplicateFileScan = limitFunction( async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise) => { const fileBuffer = await file.arrayBuffer(); @@ -76,16 +111,8 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => { }; const encryptFile = limitFunction( - async ( - status: Writable, - file: File, - fileBuffer: ArrayBuffer, - masterKey: MasterKey, - ) => { - status.update((value) => { - value.status = "encrypting"; - return value; - }); + async (state: FileUploadState, file: File, fileBuffer: ArrayBuffer, masterKey: MasterKey) => { + state.status = "encrypting"; const fileType = getFileType(file); @@ -109,10 +136,7 @@ const encryptFile = limitFunction( const thumbnailBuffer = await thumbnail?.arrayBuffer(); const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey)); - status.update((value) => { - value.status = "upload-pending"; - return value; - }); + state.status = "upload-pending"; return { dataKeyWrapped, @@ -130,20 +154,14 @@ const encryptFile = limitFunction( ); const requestFileUpload = limitFunction( - async (status: Writable, form: FormData, thumbnailForm: FormData | null) => { - status.update((value) => { - value.status = "uploading"; - return value; - }); + async (state: FileUploadState, form: FormData, thumbnailForm: FormData | null) => { + state.status = "uploading"; const res = await axios.post("/api/file/upload", form, { onUploadProgress: ({ progress, rate, estimated }) => { - status.update((value) => { - value.progress = progress; - value.rate = rate; - value.estimated = estimated; - return value; - }); + state.progress = progress; + state.rate = rate; + state.estimated = estimated; }, }); const { file }: FileUploadResponse = res.data; @@ -157,10 +175,7 @@ const requestFileUpload = limitFunction( } } - status.update((value) => { - value.status = "uploaded"; - return value; - }); + state.status = "uploaded"; return { fileId: file }; }, @@ -173,92 +188,82 @@ export const uploadFile = async ( hmacSecret: HmacSecret, masterKey: MasterKey, onDuplicate: () => Promise, -): Promise< - { fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined -> => { - const status = writable({ +) => { + uploadingFiles.push({ name: file.name, parentId, - status: "encryption-pending", - }); - fileUploadStatusStore.update((value) => { - value.push(status); - return value; + status: "queued", }); + const state = uploadingFiles.at(-1)!; - try { - const { fileBuffer, fileSigned } = await requestDuplicateFileScan( - file, - hmacSecret, - onDuplicate, - ); - if (!fileBuffer || !fileSigned) { - status.update((value) => { - value.status = "canceled"; - return value; - }); - fileUploadStatusStore.update((value) => { - value = value.filter((v) => v !== status); - return value; - }); - return undefined; - } + return await scheduler.schedule(file.size, async () => { + state.status = "encryption-pending"; - const { - dataKeyWrapped, - dataKeyVersion, - fileType, - fileEncrypted, - fileEncryptedHash, - nameEncrypted, - createdAtEncrypted, - lastModifiedAtEncrypted, - thumbnail, - } = await encryptFile(status, file, fileBuffer, masterKey); + try { + const { fileBuffer, fileSigned } = await requestDuplicateFileScan( + file, + hmacSecret, + onDuplicate, + ); + if (!fileBuffer || !fileSigned) { + state.status = "canceled"; + uploadingFiles = uploadingFiles.filter((file) => file !== state); + return undefined; + } - const form = new FormData(); - form.set( - "metadata", - JSON.stringify({ - parent: parentId, - mekVersion: masterKey.version, - dek: dataKeyWrapped, - dekVersion: dataKeyVersion.toISOString(), - hskVersion: hmacSecret.version, - contentHmac: fileSigned, - contentType: fileType, - contentIv: fileEncrypted.iv, - name: nameEncrypted.ciphertext, - nameIv: nameEncrypted.iv, - createdAt: createdAtEncrypted?.ciphertext, - createdAtIv: createdAtEncrypted?.iv, - lastModifiedAt: lastModifiedAtEncrypted.ciphertext, - lastModifiedAtIv: lastModifiedAtEncrypted.iv, - } satisfies FileUploadRequest), - ); - form.set("content", new Blob([fileEncrypted.ciphertext])); - form.set("checksum", fileEncryptedHash); + const { + dataKeyWrapped, + dataKeyVersion, + fileType, + fileEncrypted, + fileEncryptedHash, + nameEncrypted, + createdAtEncrypted, + lastModifiedAtEncrypted, + thumbnail, + } = await encryptFile(state, file, fileBuffer, masterKey); - let thumbnailForm = null; - if (thumbnail) { - thumbnailForm = new FormData(); - thumbnailForm.set( + const form = new FormData(); + form.set( "metadata", JSON.stringify({ + parent: parentId, + mekVersion: masterKey.version, + dek: dataKeyWrapped, dekVersion: dataKeyVersion.toISOString(), - contentIv: thumbnail.iv, - } satisfies FileThumbnailUploadRequest), + hskVersion: hmacSecret.version, + contentHmac: fileSigned, + contentType: fileType, + contentIv: fileEncrypted.iv, + name: nameEncrypted.ciphertext, + nameIv: nameEncrypted.iv, + createdAt: createdAtEncrypted?.ciphertext, + createdAtIv: createdAtEncrypted?.iv, + lastModifiedAt: lastModifiedAtEncrypted.ciphertext, + lastModifiedAtIv: lastModifiedAtEncrypted.iv, + } satisfies FileUploadRequest), ); - thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); - } + form.set("content", new Blob([fileEncrypted.ciphertext])); + form.set("checksum", fileEncryptedHash); - const { fileId } = await requestFileUpload(status, form, thumbnailForm); - return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; - } catch (e) { - status.update((value) => { - value.status = "error"; - return value; - }); - throw e; - } + let thumbnailForm = null; + if (thumbnail) { + thumbnailForm = new FormData(); + thumbnailForm.set( + "metadata", + JSON.stringify({ + dekVersion: dataKeyVersion.toISOString(), + contentIv: thumbnail.iv, + } satisfies FileThumbnailUploadRequest), + ); + thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); + } + + const { fileId } = await requestFileUpload(state, form, thumbnailForm); + return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; + } catch (e) { + state.status = "error"; + throw e; + } + }); }; diff --git a/src/lib/modules/filesystem.ts b/src/lib/modules/filesystem.ts deleted file mode 100644 index 9f447bf..0000000 --- a/src/lib/modules/filesystem.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { TRPCClientError } from "@trpc/client"; -import { get, writable, type Writable } from "svelte/store"; -import { - getDirectoryInfos as getDirectoryInfosFromIndexedDB, - getDirectoryInfo as getDirectoryInfoFromIndexedDB, - storeDirectoryInfo, - deleteDirectoryInfo, - getFileInfos as getFileInfosFromIndexedDB, - getFileInfo as getFileInfoFromIndexedDB, - storeFileInfo, - deleteFileInfo, - getCategoryInfos as getCategoryInfosFromIndexedDB, - getCategoryInfo as getCategoryInfoFromIndexedDB, - storeCategoryInfo, - updateCategoryInfo as updateCategoryInfoInIndexedDB, - deleteCategoryInfo, - type DirectoryId, - type CategoryId, -} from "$lib/indexedDB"; -import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; -import { trpc } from "$trpc/client"; - -export type DirectoryInfo = - | { - id: "root"; - parentId?: undefined; - dataKey?: undefined; - dataKeyVersion?: undefined; - name?: undefined; - subDirectoryIds: number[]; - fileIds: number[]; - } - | { - id: number; - parentId: DirectoryId; - dataKey?: CryptoKey; - dataKeyVersion?: Date; - name: string; - subDirectoryIds: number[]; - fileIds: number[]; - }; - -export interface FileInfo { - id: number; - parentId: DirectoryId; - dataKey?: CryptoKey; - dataKeyVersion?: Date; - contentType: string; - contentIv?: string; - name: string; - createdAt?: Date; - lastModifiedAt: Date; - categoryIds: number[]; -} - -export type CategoryInfo = - | { - id: "root"; - dataKey?: undefined; - dataKeyVersion?: undefined; - name?: undefined; - subCategoryIds: number[]; - files?: undefined; - isFileRecursive?: undefined; - } - | { - id: number; - dataKey?: CryptoKey; - dataKeyVersion?: Date; - name: string; - subCategoryIds: number[]; - files: { id: number; isRecursive: boolean }[]; - isFileRecursive: boolean; - }; - -const directoryInfoStore = new Map>(); -const fileInfoStore = new Map>(); -const categoryInfoStore = new Map>(); - -const fetchDirectoryInfoFromIndexedDB = async ( - id: DirectoryId, - info: Writable, -) => { - if (get(info)) return; - - const [directory, subDirectories, files] = await Promise.all([ - id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined, - getDirectoryInfosFromIndexedDB(id), - getFileInfosFromIndexedDB(id), - ]); - const subDirectoryIds = subDirectories.map(({ id }) => id); - const fileIds = files.map(({ id }) => id); - - if (id === "root") { - info.set({ id, subDirectoryIds, fileIds }); - } else { - if (!directory) return; - info.set({ - id, - parentId: directory.parentId, - name: directory.name, - subDirectoryIds, - fileIds, - }); - } -}; - -const fetchDirectoryInfoFromServer = async ( - id: DirectoryId, - info: Writable, - masterKey: CryptoKey, -) => { - let data; - try { - data = await trpc().directory.get.query({ id }); - } catch (e) { - if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") { - info.set(null); - await deleteDirectoryInfo(id as number); - return; - } - throw new Error("Failed to fetch directory information"); - } - - const { metadata, subDirectories: subDirectoryIds, files: fileIds } = data; - - if (id === "root") { - info.set({ id, subDirectoryIds, fileIds }); - } else { - const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey); - const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey); - - info.set({ - id, - parentId: metadata!.parent, - dataKey, - dataKeyVersion: new Date(metadata!.dekVersion), - name, - subDirectoryIds, - fileIds, - }); - await storeDirectoryInfo({ id, parentId: metadata!.parent, name }); - } -}; - -const fetchDirectoryInfo = async ( - id: DirectoryId, - info: Writable, - masterKey: CryptoKey, -) => { - await fetchDirectoryInfoFromIndexedDB(id, info); - await fetchDirectoryInfoFromServer(id, info, masterKey); -}; - -export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => { - // TODO: MEK rotation - - let info = directoryInfoStore.get(id); - if (!info) { - info = writable(null); - directoryInfoStore.set(id, info); - } - - fetchDirectoryInfo(id, info, masterKey); // Intended - return info; -}; - -const fetchFileInfoFromIndexedDB = async (id: number, info: Writable) => { - if (get(info)) return; - - const file = await getFileInfoFromIndexedDB(id); - if (!file) return; - - info.set(file); -}; - -const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => { - return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10)); -}; - -const fetchFileInfoFromServer = async ( - id: number, - info: Writable, - masterKey: CryptoKey, -) => { - let metadata; - try { - metadata = await trpc().file.get.query({ id }); - } catch (e) { - if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") { - info.set(null); - await deleteFileInfo(id); - return; - } - throw new Error("Failed to fetch file information"); - } - const { dataKey } = await unwrapDataKey(metadata.dek, masterKey); - - const name = await decryptString(metadata.name, metadata.nameIv, dataKey); - const createdAt = - metadata.createdAt && metadata.createdAtIv - ? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey) - : undefined; - const lastModifiedAt = await decryptDate( - metadata.lastModifiedAt, - metadata.lastModifiedAtIv, - dataKey, - ); - - info.set({ - id, - parentId: metadata.parent, - dataKey, - dataKeyVersion: new Date(metadata.dekVersion), - contentType: metadata.contentType, - contentIv: metadata.contentIv, - name, - createdAt, - lastModifiedAt, - categoryIds: metadata.categories, - }); - await storeFileInfo({ - id, - parentId: metadata.parent, - name, - contentType: metadata.contentType, - createdAt, - lastModifiedAt, - categoryIds: metadata.categories, - }); -}; - -const fetchFileInfo = async (id: number, info: Writable, masterKey: CryptoKey) => { - await fetchFileInfoFromIndexedDB(id, info); - await fetchFileInfoFromServer(id, info, masterKey); -}; - -export const getFileInfo = (fileId: number, masterKey: CryptoKey) => { - // TODO: MEK rotation - - let info = fileInfoStore.get(fileId); - if (!info) { - info = writable(null); - fileInfoStore.set(fileId, info); - } - - fetchFileInfo(fileId, info, masterKey); // Intended - return info; -}; - -const fetchCategoryInfoFromIndexedDB = async ( - id: CategoryId, - info: Writable, -) => { - if (get(info)) return; - - const [category, subCategories] = await Promise.all([ - id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined, - getCategoryInfosFromIndexedDB(id), - ]); - const subCategoryIds = subCategories.map(({ id }) => id); - - if (id === "root") { - info.set({ id, subCategoryIds }); - } else { - if (!category) return; - info.set({ - id, - name: category.name, - subCategoryIds, - files: category.files, - isFileRecursive: category.isFileRecursive, - }); - } -}; - -const fetchCategoryInfoFromServer = async ( - id: CategoryId, - info: Writable, - masterKey: CryptoKey, -) => { - let data; - try { - data = await trpc().category.get.query({ id }); - } catch (e) { - if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") { - info.set(null); - await deleteCategoryInfo(id as number); - return; - } - throw new Error("Failed to fetch category information"); - } - - const { metadata, subCategories } = data; - - if (id === "root") { - info.set({ id, subCategoryIds: subCategories }); - } else { - const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey); - const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey); - - let files; - try { - files = await trpc().category.files.query({ id, recurse: true }); - } catch { - throw new Error("Failed to fetch category files"); - } - - const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive })); - let isFileRecursive: boolean | undefined = undefined; - - info.update((value) => { - const newValue = { - isFileRecursive: false, - ...value, - id, - dataKey, - dataKeyVersion: new Date(metadata!.dekVersion), - name, - subCategoryIds: subCategories, - files: filesMapped, - }; - isFileRecursive = newValue.isFileRecursive; - return newValue; - }); - await storeCategoryInfo({ - id, - parentId: metadata!.parent, - name, - files: filesMapped, - isFileRecursive: isFileRecursive!, - }); - } -}; - -const fetchCategoryInfo = async ( - id: CategoryId, - info: Writable, - masterKey: CryptoKey, -) => { - await fetchCategoryInfoFromIndexedDB(id, info); - await fetchCategoryInfoFromServer(id, info, masterKey); -}; - -export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) => { - // TODO: MEK rotation - - let info = categoryInfoStore.get(categoryId); - if (!info) { - info = writable(null); - categoryInfoStore.set(categoryId, info); - } - - fetchCategoryInfo(categoryId, info, masterKey); // Intended - return info; -}; - -export const updateCategoryInfo = async ( - categoryId: number, - changes: { isFileRecursive?: boolean }, -) => { - await updateCategoryInfoInIndexedDB(categoryId, changes); - categoryInfoStore.get(categoryId)?.update((value) => { - if (!value) return value; - if (changes.isFileRecursive !== undefined) { - value.isFileRecursive = changes.isFileRecursive; - } - return value; - }); -}; diff --git a/src/lib/modules/filesystem/category.ts b/src/lib/modules/filesystem/category.ts new file mode 100644 index 0000000..778f75c --- /dev/null +++ b/src/lib/modules/filesystem/category.ts @@ -0,0 +1,121 @@ +import * as IndexedDB from "$lib/indexedDB"; +import { trpc, isTRPCClientError } from "$trpc/client"; +import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte"; +import type { CategoryInfo, MaybeCategoryInfo } from "./types"; + +const cache = new FilesystemCache({ + async fetchFromIndexedDB(id) { + const [category, subCategories] = await Promise.all([ + id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined, + IndexedDB.getCategoryInfos(id), + ]); + const files = category?.files + ? await Promise.all( + category.files.map(async (file) => { + const fileInfo = await IndexedDB.getFileInfo(file.id); + return fileInfo + ? { + id: file.id, + parentId: fileInfo.parentId, + contentType: fileInfo.contentType, + name: fileInfo.name, + createdAt: fileInfo.createdAt, + lastModifiedAt: fileInfo.lastModifiedAt, + isRecursive: file.isRecursive, + } + : undefined; + }), + ) + : undefined; + + if (id === "root") { + return { + id, + exists: true, + subCategories, + }; + } else if (category) { + return { + id, + exists: true, + parentId: category.parentId, + name: category.name, + subCategories, + files: files?.filter((file) => !!file) ?? [], + isFileRecursive: category.isFileRecursive ?? false, + }; + } + }, + + async fetchFromServer(id, cachedInfo, masterKey) { + try { + const category = await trpc().category.get.query({ id, recurse: true }); + const [subCategories, files, metadata] = await Promise.all([ + Promise.all( + category.subCategories.map(async (category) => ({ + id: category.id, + parentId: id, + ...(await decryptCategoryMetadata(category, masterKey)), + })), + ), + category.files && + Promise.all( + category.files.map(async (file) => ({ + id: file.id, + parentId: file.parent, + contentType: file.contentType, + isRecursive: file.isRecursive, + ...(await decryptFileMetadata(file, masterKey)), + })), + ), + category.metadata && decryptCategoryMetadata(category.metadata, masterKey), + ]); + + return storeToIndexedDB( + id !== "root" + ? { + id, + parentId: category.metadata!.parent, + subCategories, + files: files!, + isFileRecursive: cachedInfo?.isFileRecursive ?? false, + ...metadata!, + } + : { id, subCategories }, + ); + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + await IndexedDB.deleteCategoryInfo(id as number); + return { id, exists: false }; + } + throw e; + } + }, +}); + +const storeToIndexedDB = (info: CategoryInfo) => { + if (info.id !== "root") { + void IndexedDB.storeCategoryInfo(info); + + // TODO: Bulk Upsert + new Map(info.files.map((file) => [file.id, file])).forEach((file) => { + void IndexedDB.storeFileInfo(file); + }); + } + + // TODO: Bulk Upsert + info.subCategories.forEach((category) => { + void IndexedDB.storeCategoryInfo(category); + }); + + void IndexedDB.deleteDanglingCategoryInfos( + info.id, + new Set(info.subCategories.map(({ id }) => id)), + ); + + return { ...info, exists: true as const }; +}; + +export const getCategoryInfo = (id: CategoryId, masterKey: CryptoKey) => { + return cache.get(id, masterKey); +}; diff --git a/src/lib/modules/filesystem/directory.ts b/src/lib/modules/filesystem/directory.ts new file mode 100644 index 0000000..4144a68 --- /dev/null +++ b/src/lib/modules/filesystem/directory.ts @@ -0,0 +1,102 @@ +import * as IndexedDB from "$lib/indexedDB"; +import { trpc, isTRPCClientError } from "$trpc/client"; +import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte"; +import type { DirectoryInfo, MaybeDirectoryInfo } from "./types"; + +const cache = new FilesystemCache({ + async fetchFromIndexedDB(id) { + const [directory, subDirectories, files] = await Promise.all([ + id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined, + IndexedDB.getDirectoryInfos(id), + IndexedDB.getFileInfos(id), + ]); + + if (id === "root") { + return { + id, + exists: true, + subDirectories, + files, + }; + } else if (directory) { + return { + id, + exists: true, + parentId: directory.parentId, + name: directory.name, + subDirectories, + files, + }; + } + }, + + async fetchFromServer(id, _cachedInfo, masterKey) { + try { + const directory = await trpc().directory.get.query({ id }); + const [subDirectories, files, metadata] = await Promise.all([ + Promise.all( + directory.subDirectories.map(async (directory) => ({ + id: directory.id, + parentId: id, + ...(await decryptDirectoryMetadata(directory, masterKey)), + })), + ), + Promise.all( + directory.files.map(async (file) => ({ + id: file.id, + parentId: id, + contentType: file.contentType, + ...(await decryptFileMetadata(file, masterKey)), + })), + ), + directory.metadata && decryptDirectoryMetadata(directory.metadata, masterKey), + ]); + + return storeToIndexedDB( + id !== "root" + ? { + id, + parentId: directory.metadata!.parent, + subDirectories, + files, + ...metadata!, + } + : { id, subDirectories, files }, + ); + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + await IndexedDB.deleteDirectoryInfo(id as number); + return { id, exists: false as const }; + } + throw e; + } + }, +}); + +const storeToIndexedDB = (info: DirectoryInfo) => { + if (info.id !== "root") { + void IndexedDB.storeDirectoryInfo(info); + } + + // TODO: Bulk Upsert + info.subDirectories.forEach((subDirectory) => { + void IndexedDB.storeDirectoryInfo(subDirectory); + }); + + // TODO: Bulk Upsert + info.files.forEach((file) => { + void IndexedDB.storeFileInfo(file); + }); + + void IndexedDB.deleteDanglingDirectoryInfos( + info.id, + new Set(info.subDirectories.map(({ id }) => id)), + ); + void IndexedDB.deleteDanglingFileInfos(info.id, new Set(info.files.map(({ id }) => id))); + + return { ...info, exists: true as const }; +}; + +export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => { + return cache.get(id, masterKey); +}; diff --git a/src/lib/modules/filesystem/file.ts b/src/lib/modules/filesystem/file.ts new file mode 100644 index 0000000..7d5feb9 --- /dev/null +++ b/src/lib/modules/filesystem/file.ts @@ -0,0 +1,177 @@ +import * as IndexedDB from "$lib/indexedDB"; +import { trpc, isTRPCClientError } from "$trpc/client"; +import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte"; +import type { FileInfo, MaybeFileInfo } from "./types"; + +const cache = new FilesystemCache({ + async fetchFromIndexedDB(id) { + const file = await IndexedDB.getFileInfo(id); + const categories = file?.categoryIds + ? await Promise.all( + file.categoryIds.map(async (categoryId) => { + const category = await IndexedDB.getCategoryInfo(categoryId); + return category + ? { id: category.id, parentId: category.parentId, name: category.name } + : undefined; + }), + ) + : undefined; + + if (file) { + return { + id, + exists: true, + parentId: file.parentId, + contentType: file.contentType, + name: file.name, + createdAt: file.createdAt, + lastModifiedAt: file.lastModifiedAt, + categories: categories?.filter((category) => !!category) ?? [], + }; + } + }, + + async fetchFromServer(id, _cachedInfo, masterKey) { + try { + const file = await trpc().file.get.query({ id }); + const [categories, metadata] = await Promise.all([ + Promise.all( + file.categories.map(async (category) => ({ + id: category.id, + parentId: category.parent, + ...(await decryptCategoryMetadata(category, masterKey)), + })), + ), + decryptFileMetadata(file, masterKey), + ]); + + return storeToIndexedDB({ + id, + parentId: file.parent, + dataKey: metadata.dataKey, + contentType: file.contentType, + contentIv: file.contentIv, + name: metadata.name, + createdAt: metadata.createdAt, + lastModifiedAt: metadata.lastModifiedAt, + categories, + }); + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + await IndexedDB.deleteFileInfo(id); + return { id, exists: false as const }; + } + throw e; + } + }, + + async bulkFetchFromIndexedDB(ids) { + const files = await IndexedDB.bulkGetFileInfos([...ids]); + const categories = await Promise.all( + files.map(async (file) => + file?.categoryIds + ? await Promise.all( + file.categoryIds.map(async (categoryId) => { + const category = await IndexedDB.getCategoryInfo(categoryId); + return category + ? { id: category.id, parentId: category.parentId, name: category.name } + : undefined; + }), + ) + : undefined, + ), + ); + + return new Map( + files + .filter((file) => !!file) + .map((file, index) => [ + file.id, + { + ...file, + exists: true, + categories: categories[index]?.filter((category) => !!category) ?? [], + }, + ]), + ); + }, + + async bulkFetchFromServer(ids, masterKey) { + const idsArray = [...ids.keys()]; + + const filesRaw = await trpc().file.bulkGet.query({ ids: idsArray }); + const files = await Promise.all( + filesRaw.map(async ({ id, categories: categoriesRaw, ...metadataRaw }) => { + const [categories, metadata] = await Promise.all([ + Promise.all( + categoriesRaw.map(async (category) => ({ + id: category.id, + parentId: category.parent, + ...(await decryptCategoryMetadata(category, masterKey)), + })), + ), + decryptFileMetadata(metadataRaw, masterKey), + ]); + + return { + id, + exists: true as const, + parentId: metadataRaw.parent, + contentType: metadataRaw.contentType, + contentIv: metadataRaw.contentIv, + categories, + ...metadata, + }; + }), + ); + + const existingIds = new Set(filesRaw.map(({ id }) => id)); + const deletedIds = idsArray.filter((id) => !existingIds.has(id)); + + void IndexedDB.bulkDeleteFileInfos(deletedIds); + return new Map([ + ...bulkStoreToIndexedDB(files), + ...deletedIds.map((id) => [id, { id, exists: false }] as const), + ]); + }, +}); + +const storeToIndexedDB = (info: FileInfo) => { + void IndexedDB.storeFileInfo({ + ...info, + categoryIds: info.categories.map(({ id }) => id), + }); + + info.categories.forEach((category) => { + void IndexedDB.storeCategoryInfo(category); + }); + + return { ...info, exists: true as const }; +}; + +const bulkStoreToIndexedDB = (infos: FileInfo[]) => { + // TODO: Bulk Upsert + infos.forEach((info) => { + void IndexedDB.storeFileInfo({ + ...info, + categoryIds: info.categories.map(({ id }) => id), + }); + }); + + // TODO: Bulk Upsert + new Map( + infos.flatMap(({ categories }) => categories).map((category) => [category.id, category]), + ).forEach((category) => { + void IndexedDB.storeCategoryInfo(category); + }); + + return infos.map((info) => [info.id, { ...info, exists: true }] as const); +}; + +export const getFileInfo = (id: number, masterKey: CryptoKey) => { + return cache.get(id, masterKey); +}; + +export const bulkGetFileInfo = (ids: number[], masterKey: CryptoKey) => { + return cache.bulkGet(new Set(ids), masterKey); +}; diff --git a/src/lib/modules/filesystem/index.ts b/src/lib/modules/filesystem/index.ts new file mode 100644 index 0000000..cb9e0f4 --- /dev/null +++ b/src/lib/modules/filesystem/index.ts @@ -0,0 +1,4 @@ +export * from "./category"; +export * from "./directory"; +export * from "./file"; +export * from "./types"; diff --git a/src/lib/modules/filesystem/internal.svelte.ts b/src/lib/modules/filesystem/internal.svelte.ts new file mode 100644 index 0000000..7a8c446 --- /dev/null +++ b/src/lib/modules/filesystem/internal.svelte.ts @@ -0,0 +1,172 @@ +import { untrack } from "svelte"; +import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; + +interface FilesystemCacheOptions { + fetchFromIndexedDB: (key: K) => Promise; + fetchFromServer: (key: K, cachedValue: V | undefined, masterKey: CryptoKey) => Promise; + bulkFetchFromIndexedDB?: (keys: Set) => Promise>; + bulkFetchFromServer?: ( + keys: Map, + masterKey: CryptoKey, + ) => Promise>; +} + +export class FilesystemCache { + private map = new Map }>(); + + constructor(private readonly options: FilesystemCacheOptions) {} + + get(key: K, masterKey: CryptoKey) { + return untrack(() => { + let state = this.map.get(key); + if (state?.promise) return state.value ?? state.promise; + + const { promise: newPromise, resolve } = Promise.withResolvers(); + + if (!state) { + const newState = $state({}); + state = newState; + this.map.set(key, newState); + } + + (state.value + ? Promise.resolve(state.value) + : this.options.fetchFromIndexedDB(key).then((loadedInfo) => { + if (loadedInfo) { + state.value = loadedInfo; + resolve(state.value); + } + return loadedInfo; + }) + ) + .then((cachedInfo) => this.options.fetchFromServer(key, cachedInfo, masterKey)) + .then((loadedInfo) => { + if (state.value) { + Object.assign(state.value, loadedInfo); + } else { + state.value = loadedInfo; + } + resolve(state.value); + }) + .finally(() => { + state.promise = undefined; + }); + + state.promise = newPromise; + return state.value ?? newPromise; + }); + } + + bulkGet(keys: Set, masterKey: CryptoKey) { + return untrack(() => { + const newPromises = new Map( + keys + .keys() + .filter((key) => this.map.get(key)?.promise === undefined) + .map((key) => [key, Promise.withResolvers()]), + ); + newPromises.forEach(({ promise }, key) => { + const state = this.map.get(key); + if (state) { + state.promise = promise; + } else { + const newState = $state({ promise }); + this.map.set(key, newState); + } + }); + + const resolve = (loadedInfos: Map) => { + loadedInfos.forEach((loadedInfo, key) => { + const state = this.map.get(key)!; + if (state.value) { + Object.assign(state.value, loadedInfo); + } else { + state.value = loadedInfo; + } + newPromises.get(key)!.resolve(state.value); + }); + return loadedInfos; + }; + + this.options.bulkFetchFromIndexedDB!( + new Set(newPromises.keys().filter((key) => this.map.get(key)!.value === undefined)), + ) + .then(resolve) + .then(() => + this.options.bulkFetchFromServer!( + new Map( + newPromises.keys().map((key) => [key, { cachedValue: this.map.get(key)!.value }]), + ), + masterKey, + ), + ) + .then(resolve) + .finally(() => { + newPromises.forEach((_, key) => { + this.map.get(key)!.promise = undefined; + }); + }); + + const bottleneckPromises = Array.from( + keys + .keys() + .filter((key) => this.map.get(key)!.value === undefined) + .map((key) => this.map.get(key)!.promise!), + ); + const makeResult = () => + new Map(keys.keys().map((key) => [key, this.map.get(key)!.value!] as const)); + return bottleneckPromises.length > 0 + ? Promise.all(bottleneckPromises).then(makeResult) + : makeResult(); + }); + } +} + +export const decryptDirectoryMetadata = async ( + metadata: { dek: string; dekVersion: Date; name: string; nameIv: string }, + masterKey: CryptoKey, +) => { + const { dataKey } = await unwrapDataKey(metadata.dek, masterKey); + const name = await decryptString(metadata.name, metadata.nameIv, dataKey); + + return { + dataKey: { key: dataKey, version: metadata.dekVersion }, + name, + }; +}; + +const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => { + return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10)); +}; + +export const decryptFileMetadata = async ( + metadata: { + dek: string; + dekVersion: Date; + name: string; + nameIv: string; + createdAt?: string; + createdAtIv?: string; + lastModifiedAt: string; + lastModifiedAtIv: string; + }, + masterKey: CryptoKey, +) => { + const { dataKey } = await unwrapDataKey(metadata.dek, masterKey); + const [name, createdAt, lastModifiedAt] = await Promise.all([ + decryptString(metadata.name, metadata.nameIv, dataKey), + metadata.createdAt + ? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey) + : undefined, + decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey), + ]); + + return { + dataKey: { key: dataKey, version: metadata.dekVersion }, + name, + createdAt, + lastModifiedAt, + }; +}; + +export const decryptCategoryMetadata = decryptDirectoryMetadata; diff --git a/src/lib/modules/filesystem/types.ts b/src/lib/modules/filesystem/types.ts new file mode 100644 index 0000000..9f33113 --- /dev/null +++ b/src/lib/modules/filesystem/types.ts @@ -0,0 +1,77 @@ +export type DataKey = { key: CryptoKey; version: Date }; +type AllUndefined = { [K in keyof T]?: undefined }; + +interface LocalDirectoryInfo { + id: number; + parentId: DirectoryId; + dataKey?: DataKey; + name: string; + subDirectories: SubDirectoryInfo[]; + files: SummarizedFileInfo[]; +} + +interface RootDirectoryInfo { + id: "root"; + parentId?: undefined; + dataKey?: undefined; + name?: undefined; + subDirectories: SubDirectoryInfo[]; + files: SummarizedFileInfo[]; +} + +export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo; +export type MaybeDirectoryInfo = + | (DirectoryInfo & { exists: true }) + | ({ id: DirectoryId; exists: false } & AllUndefined>); + +export type SubDirectoryInfo = Omit; + +export interface FileInfo { + id: number; + parentId: DirectoryId; + dataKey?: DataKey; + contentType: string; + contentIv?: string; + name: string; + createdAt?: Date; + lastModifiedAt: Date; + categories: FileCategoryInfo[]; +} + +export type MaybeFileInfo = + | (FileInfo & { exists: true }) + | ({ id: number; exists: false } & AllUndefined>); + +export type SummarizedFileInfo = Omit; +export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean }; + +interface LocalCategoryInfo { + id: number; + parentId: DirectoryId; + dataKey?: DataKey; + name: string; + subCategories: SubCategoryInfo[]; + files: CategoryFileInfo[]; + isFileRecursive: boolean; +} + +interface RootCategoryInfo { + id: "root"; + parentId?: undefined; + dataKey?: undefined; + name?: undefined; + subCategories: SubCategoryInfo[]; + files?: undefined; + isFileRecursive?: undefined; +} + +export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo; +export type MaybeCategoryInfo = + | (CategoryInfo & { exists: true }) + | ({ id: CategoryId; exists: false } & AllUndefined>); + +export type SubCategoryInfo = Omit< + LocalCategoryInfo, + "subCategories" | "files" | "isFileRecursive" +>; +export type FileCategoryInfo = Omit; diff --git a/src/lib/modules/scheduler.ts b/src/lib/modules/scheduler.ts new file mode 100644 index 0000000..4216db3 --- /dev/null +++ b/src/lib/modules/scheduler.ts @@ -0,0 +1,48 @@ +export class Scheduler { + private isEstimating = false; + private memoryUsage = 0; + private queue: (() => void)[] = []; + + constructor(public readonly memoryLimit = 100 * 1024 * 1024 /* 100 MiB */) {} + + private next() { + if (!this.isEstimating && this.memoryUsage < this.memoryLimit) { + const resolve = this.queue.shift(); + if (resolve) { + this.isEstimating = true; + resolve(); + } + } + } + + async schedule( + estimateMemoryUsage: number | (() => number | Promise), + task: () => Promise, + ) { + if (this.isEstimating || this.memoryUsage >= this.memoryLimit) { + await new Promise((resolve) => { + this.queue.push(resolve); + }); + } else { + this.isEstimating = true; + } + + let taskMemoryUsage = 0; + + try { + taskMemoryUsage = + typeof estimateMemoryUsage === "number" ? estimateMemoryUsage : await estimateMemoryUsage(); + this.memoryUsage += taskMemoryUsage; + } finally { + this.isEstimating = false; + this.next(); + } + + try { + return await task(); + } finally { + this.memoryUsage -= taskMemoryUsage; + this.next(); + } + } +} diff --git a/src/lib/server/db/category.ts b/src/lib/server/db/category.ts index f5c22ff..e20138c 100644 --- a/src/lib/server/db/category.ts +++ b/src/lib/server/db/category.ts @@ -2,8 +2,6 @@ import { IntegrityError } from "./error"; import db from "./kysely"; import type { Ciphertext } from "./schema"; -export type CategoryId = "root" | number; - interface Category { id: number; parentId: CategoryId; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index c3169fc..472930a 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -1,11 +1,10 @@ -import { sql, type NotNull } from "kysely"; +import { sql } from "kysely"; +import { jsonArrayFrom } from "kysely/helpers/postgres"; import pg from "pg"; import { IntegrityError } from "./error"; import db from "./kysely"; import type { Ciphertext } from "./schema"; -export type DirectoryId = "root" | number; - interface Directory { id: number; parentId: DirectoryId; @@ -38,6 +37,15 @@ interface File { export type NewFile = Omit; +interface FileCategory { + id: number; + parentId: CategoryId; + mekVersion: number; + encDek: string; + dekVersion: Date; + encName: Ciphertext; +} + export const registerDirectory = async (params: NewDirectory) => { await db.transaction().execute(async (trx) => { const mek = await trx @@ -306,39 +314,51 @@ export const getAllFilesByCategory = async ( recurse: boolean, ) => { const files = await db - .withRecursive("cte", (db) => + .withRecursive("category_tree", (db) => db .selectFrom("category") - .leftJoin("file_category", "category.id", "file_category.category_id") - .select(["id", "parent_id", "user_id", "file_category.file_id"]) - .select(sql`0`.as("depth")) + .select(["id", sql`0`.as("depth")]) .where("id", "=", categoryId) + .where("user_id", "=", userId) .$if(recurse, (qb) => qb.unionAll((db) => db .selectFrom("category") - .leftJoin("file_category", "category.id", "file_category.category_id") - .innerJoin("cte", "category.parent_id", "cte.id") - .select([ - "category.id", - "category.parent_id", - "category.user_id", - "file_category.file_id", - ]) - .select(sql`cte.depth + 1`.as("depth")), + .innerJoin("category_tree", "category.parent_id", "category_tree.id") + .select(["category.id", sql`depth + 1`.as("depth")]), ), ), ) - .selectFrom("cte") + .selectFrom("category_tree") + .innerJoin("file_category", "category_tree.id", "file_category.category_id") + .innerJoin("file", "file_category.file_id", "file.id") .select(["file_id", "depth"]) + .selectAll("file") .distinctOn("file_id") - .where("user_id", "=", userId) - .where("file_id", "is not", null) - .$narrowType<{ file_id: NotNull }>() .orderBy("file_id") .orderBy("depth") .execute(); - return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 })); + return files.map( + (file) => + ({ + id: file.file_id, + parentId: file.parent_id ?? "root", + userId: file.user_id, + path: file.path, + mekVersion: file.master_encryption_key_version, + encDek: file.encrypted_data_encryption_key, + dekVersion: file.data_encryption_key_version, + hskVersion: file.hmac_secret_key_version, + contentHmac: file.content_hmac, + contentType: file.content_type, + encContentIv: file.encrypted_content_iv, + encContentHash: file.encrypted_content_hash, + encName: file.encrypted_name, + encCreatedAt: file.encrypted_created_at, + encLastModifiedAt: file.encrypted_last_modified_at, + isRecursive: file.depth > 0, + }) satisfies File & { isRecursive: boolean }, + ); }; export const getAllFileIds = async (userId: number) => { @@ -390,6 +410,52 @@ export const getFile = async (userId: number, fileId: number) => { : null; }; +export const getFilesWithCategories = async (userId: number, fileIds: number[]) => { + const files = await db + .selectFrom("file") + .selectAll() + .select((eb) => + jsonArrayFrom( + eb + .selectFrom("file_category") + .innerJoin("category", "file_category.category_id", "category.id") + .where("file_category.file_id", "=", eb.ref("file.id")) + .selectAll("category"), + ).as("categories"), + ) + .where("id", "=", (eb) => eb.fn.any(eb.val(fileIds))) + .where("user_id", "=", userId) + .execute(); + return files.map( + (file) => + ({ + id: file.id, + parentId: file.parent_id ?? "root", + userId: file.user_id, + path: file.path, + mekVersion: file.master_encryption_key_version, + encDek: file.encrypted_data_encryption_key, + dekVersion: file.data_encryption_key_version, + hskVersion: file.hmac_secret_key_version, + contentHmac: file.content_hmac, + contentType: file.content_type, + encContentIv: file.encrypted_content_iv, + encContentHash: file.encrypted_content_hash, + encName: file.encrypted_name, + encCreatedAt: file.encrypted_created_at, + encLastModifiedAt: file.encrypted_last_modified_at, + categories: file.categories.map((category) => ({ + id: category.id, + parentId: category.parent_id ?? "root", + mekVersion: category.master_encryption_key_version, + encDek: category.encrypted_data_encryption_key, + dekVersion: new Date(category.data_encryption_key_version), + encName: category.encrypted_name, + })), + }) satisfies File & { categories: FileCategory[] }, + ); +}; + export const setFileEncName = async ( userId: number, fileId: number, @@ -476,10 +542,21 @@ export const addFileToCategory = async (fileId: number, categoryId: number) => { export const getAllFileCategories = async (fileId: number) => { const categories = await db .selectFrom("file_category") - .select("category_id") + .innerJoin("category", "file_category.category_id", "category.id") + .selectAll("category") .where("file_id", "=", fileId) .execute(); - return categories.map(({ category_id }) => ({ id: category_id })); + return categories.map( + (category) => + ({ + id: category.id, + parentId: category.parent_id ?? "root", + mekVersion: category.master_encryption_key_version, + encDek: category.encrypted_data_encryption_key, + dekVersion: category.data_encryption_key_version, + encName: category.encrypted_name, + }) satisfies FileCategory, + ); }; export const removeFileFromCategory = async (fileId: number, categoryId: number) => { diff --git a/src/lib/services/auth.ts b/src/lib/services/auth.ts index 56d67eb..95f153e 100644 --- a/src/lib/services/auth.ts +++ b/src/lib/services/auth.ts @@ -1,6 +1,5 @@ -import { TRPCClientError } from "@trpc/client"; import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto"; -import { trpc } from "$trpc/client"; +import { trpc, isTRPCClientError } from "$trpc/client"; export const requestSessionUpgrade = async ( encryptKeyBase64: string, @@ -16,7 +15,7 @@ export const requestSessionUpgrade = async ( sigPubKey: verifyKeyBase64, })); } catch (e) { - if (e instanceof TRPCClientError && e.data?.code === "FORBIDDEN") { + if (isTRPCClientError(e) && e.data?.code === "FORBIDDEN") { return [false, "Unregistered client"] as const; } return [false] as const; @@ -31,7 +30,7 @@ export const requestSessionUpgrade = async ( force, }); } catch (e) { - if (e instanceof TRPCClientError && e.data?.code === "CONFLICT") { + if (isTRPCClientError(e) && e.data?.code === "CONFLICT") { return [false, "Already logged in"] as const; } return [false] as const; diff --git a/src/lib/services/file.ts b/src/lib/services/file.ts index da05824..05a92e1 100644 --- a/src/lib/services/file.ts +++ b/src/lib/services/file.ts @@ -1,15 +1,11 @@ import { getAllFileInfos } from "$lib/indexedDB/filesystem"; -import { decryptData } from "$lib/modules/crypto"; import { getFileCache, storeFileCache, deleteFileCache, - getFileThumbnailCache, - storeFileThumbnailCache, - deleteFileThumbnailCache, downloadFile, + deleteFileThumbnailCache, } from "$lib/modules/file"; -import { getThumbnailUrl } from "$lib/modules/thumbnail"; import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; import { trpc } from "$trpc/client"; @@ -44,29 +40,6 @@ export const requestFileThumbnailUpload = async ( return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form }); }; -export const requestFileThumbnailDownload = async (fileId: number, dataKey?: CryptoKey) => { - const cache = await getFileThumbnailCache(fileId); - if (cache || !dataKey) return cache; - - let thumbnailInfo; - try { - thumbnailInfo = await trpc().file.thumbnail.query({ id: fileId }); - } catch { - // TODO: Error Handling - return null; - } - const { contentIv: thumbnailEncryptedIv } = thumbnailInfo; - - const res = await fetch(`/api/file/${fileId}/thumbnail/download`); - if (!res.ok) return null; - - const thumbnailEncrypted = await res.arrayBuffer(); - const thumbnailBuffer = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey); - - storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended - return getThumbnailUrl(thumbnailBuffer); -}; - export const requestDeletedFilesCleanup = async () => { let liveFiles; try { diff --git a/src/lib/services/key.ts b/src/lib/services/key.ts index fd89f74..4e97546 100644 --- a/src/lib/services/key.ts +++ b/src/lib/services/key.ts @@ -1,4 +1,3 @@ -import { TRPCClientError } from "@trpc/client"; import { storeMasterKeys } from "$lib/indexedDB"; import { encodeToBase64, @@ -11,7 +10,7 @@ import { } from "$lib/modules/crypto"; import { requestSessionUpgrade } from "$lib/services/auth"; import { masterKeyStore, type ClientKeys } from "$lib/stores"; -import { trpc } from "$trpc/client"; +import { trpc, isTRPCClientError } from "$trpc/client"; export const requestClientRegistration = async ( encryptKeyBase64: string, @@ -112,10 +111,7 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async ( mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey), }); } catch (e) { - if ( - e instanceof TRPCClientError && - (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT") - ) { + if (isTRPCClientError(e) && (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")) { return true; } // TODO: Error Handling diff --git a/src/lib/stores/file.ts b/src/lib/stores/file.ts deleted file mode 100644 index 61db95d..0000000 --- a/src/lib/stores/file.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { writable, type Writable } from "svelte/store"; - -export interface FileUploadStatus { - name: string; - parentId: "root" | number; - status: - | "encryption-pending" - | "encrypting" - | "upload-pending" - | "uploading" - | "uploaded" - | "canceled" - | "error"; - progress?: number; - rate?: number; - estimated?: number; -} - -export interface FileDownloadStatus { - id: number; - status: - | "download-pending" - | "downloading" - | "decryption-pending" - | "decrypting" - | "decrypted" - | "canceled" - | "error"; - progress?: number; - rate?: number; - estimated?: number; - result?: ArrayBuffer; -} - -export const fileUploadStatusStore = writable[]>([]); - -export const fileDownloadStatusStore = writable[]>([]); - -export const isFileUploading = ( - status: FileUploadStatus["status"], -): status is "encryption-pending" | "encrypting" | "upload-pending" | "uploading" => { - return ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status); -}; - -export const isFileDownloading = ( - status: FileDownloadStatus["status"], -): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => { - return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status); -}; diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 537209a..668f46f 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -1,2 +1 @@ -export * from "./file"; export * from "./key"; diff --git a/src/lib/types/filesystem.d.ts b/src/lib/types/filesystem.d.ts new file mode 100644 index 0000000..2cb91a7 --- /dev/null +++ b/src/lib/types/filesystem.d.ts @@ -0,0 +1,2 @@ +type DirectoryId = "root" | number; +type CategoryId = "root" | number; diff --git a/src/lib/utils/HybridPromise.ts b/src/lib/utils/HybridPromise.ts new file mode 100644 index 0000000..10c6be9 --- /dev/null +++ b/src/lib/utils/HybridPromise.ts @@ -0,0 +1,93 @@ +type MaybePromise = T | Promise | HybridPromise; + +type HybridPromiseState = + | { mode: "sync"; status: "fulfilled"; value: T } + | { mode: "sync"; status: "rejected"; reason: unknown } + | { mode: "async"; promise: Promise }; + +export class HybridPromise implements PromiseLike { + private isConsumed = false; + + private constructor(private readonly state: HybridPromiseState) { + if (state.mode === "sync" && state.status === "rejected") { + queueMicrotask(() => { + if (!this.isConsumed) { + throw state.reason; + } + }); + } + } + + isSync(): boolean { + return this.state.mode === "sync"; + } + + toPromise(): Promise { + this.isConsumed = true; + + if (this.state.mode === "async") return this.state.promise; + return this.state.status === "fulfilled" + ? Promise.resolve(this.state.value) + : Promise.reject(this.state.reason); + } + + static resolve(value: MaybePromise): HybridPromise { + if (value instanceof HybridPromise) return value; + return new HybridPromise( + value instanceof Promise + ? { mode: "async", promise: value } + : { mode: "sync", status: "fulfilled", value }, + ); + } + + static reject(reason?: unknown): HybridPromise { + return new HybridPromise({ mode: "sync", status: "rejected", reason }); + } + + then( + onfulfilled?: ((value: T) => MaybePromise) | null | undefined, + onrejected?: ((reason: unknown) => MaybePromise) | null | undefined, + ): HybridPromise { + this.isConsumed = true; + + if (this.state.mode === "async") { + return new HybridPromise({ + mode: "async", + promise: this.state.promise.then(onfulfilled, onrejected) as any, + }); + } + + try { + if (this.state.status === "fulfilled") { + if (!onfulfilled) return HybridPromise.resolve(this.state.value as any); + return HybridPromise.resolve(onfulfilled(this.state.value)); + } else { + if (!onrejected) return HybridPromise.reject(this.state.reason); + return HybridPromise.resolve(onrejected(this.state.reason)); + } + } catch (e) { + return HybridPromise.reject(e); + } + } + + catch( + onrejected?: ((reason: unknown) => MaybePromise) | null | undefined, + ): HybridPromise { + return this.then(null, onrejected); + } + + finally(onfinally?: (() => void) | null | undefined): HybridPromise { + this.isConsumed = true; + + if (this.state.mode === "async") { + return new HybridPromise({ mode: "async", promise: this.state.promise.finally(onfinally) }); + } + + try { + onfinally?.(); + return new HybridPromise(this.state); + } catch (e) { + return HybridPromise.reject(e); + } + } +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 1db9577..5d5b9d4 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,3 +1,4 @@ export * from "./format"; export * from "./gotoStateful"; +export * from "./HybridPromise"; export * from "./sort"; diff --git a/src/lib/utils/sort.ts b/src/lib/utils/sort.ts index 2385e55..2cbcbf7 100644 --- a/src/lib/utils/sort.ts +++ b/src/lib/utils/sort.ts @@ -32,7 +32,7 @@ const sortByDateAsc: SortFunc = ({ date: a }, { date: b }) => { const sortByDateDesc: SortFunc = (a, b) => -sortByDateAsc(a, b); -export const sortEntries = (entries: T[], sortBy: SortBy) => { +export const sortEntries = (entries: T[], sortBy = SortBy.NAME_ASC) => { let sortFunc: SortFunc; switch (sortBy) { @@ -48,10 +48,12 @@ export const sortEntries = (entries: T[], sortBy: SortBy) = case SortBy.DATE_DESC: sortFunc = sortByDateDesc; break; - default: + default: { const exhaustive: never = sortBy; sortFunc = exhaustive; + } } entries.sort(sortFunc); + return entries; }; diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 3249bf2..0b344bc 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -1,19 +1,15 @@ -{#if $category} +{#if categoryInfo?.exists} - (category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))} + HybridPromise.resolve(getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)).then( + (result) => (categoryInfo = result), + )} onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} subCategoryCreatePosition="top" /> - {#if $category.id !== "root"} + {#if categoryInfo.id !== "root"} - {/if} @@ -50,8 +54,8 @@ { - if (await requestCategoryCreation(name, $category!.id, $masterKeyStore?.get(1)!)) { - category = getCategoryInfo($category!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + if (await requestCategoryCreation(name, categoryInfo!.id, $masterKeyStore?.get(1)!)) { + void getCategoryInfo(categoryInfo!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME return true; } return false; diff --git a/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte b/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte index 150669e..392cf6b 100644 --- a/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte +++ b/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte @@ -1,32 +1,31 @@ -{#if $status && isFileDownloading($status.status)} +{#if isFileDownloading(state.status)}

- {#if $status.status === "download-pending"} + {#if state.status === "download-pending"} 다운로드를 기다리는 중 - {:else if $status.status === "downloading"} + {:else if state.status === "downloading"} 다운로드하는 중 - {:else if $status.status === "decryption-pending"} + {:else if state.status === "decryption-pending"} 복호화를 기다리는 중 - {:else if $status.status === "decrypting"} + {:else if state.status === "decrypting"} 복호화하는 중 {/if}

- {#if $status.status === "downloading"} + {#if state.status === "downloading"} 전송됨 - {Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)} + {Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)} {/if}

diff --git a/src/routes/(fullscreen)/file/downloads/+page.svelte b/src/routes/(fullscreen)/file/downloads/+page.svelte index e1bad0e..860901f 100644 --- a/src/routes/(fullscreen)/file/downloads/+page.svelte +++ b/src/routes/(fullscreen)/file/downloads/+page.svelte @@ -1,19 +1,31 @@ @@ -23,8 +35,10 @@
- {#each downloadingFiles as status} - + {#each downloadingFiles as { info, state } (info.id)} + {#if info.exists} + + {/if} {/each}
diff --git a/src/routes/(fullscreen)/file/downloads/File.svelte b/src/routes/(fullscreen)/file/downloads/File.svelte index 3bfe292..d70428e 100644 --- a/src/routes/(fullscreen)/file/downloads/File.svelte +++ b/src/routes/(fullscreen)/file/downloads/File.svelte @@ -1,7 +1,6 @@ -{#if $fileInfo} -
-
- {#if $status.status === "download-pending"} - - {:else if $status.status === "downloading"} - - {:else if $status.status === "decryption-pending"} - - {:else if $status.status === "decrypting"} - - {:else if $status.status === "decrypted"} - - {:else if $status.status === "error"} - - {/if} -
-
-

- {$fileInfo.name} -

-

- {#if $status.status === "download-pending"} - 다운로드를 기다리는 중 - {:else if $status.status === "downloading"} - 전송됨 - {Math.floor(($status.progress ?? 0) * 100)}% · - {formatNetworkSpeed(($status.rate ?? 0) * 8)} - {:else if $status.status === "decryption-pending"} - 복호화를 기다리는 중 - {:else if $status.status === "decrypting"} - 복호화하는 중 - {:else if $status.status === "decrypted"} - 다운로드 완료 - {:else if $status.status === "error"} - 다운로드 실패 - {/if} -

-
+
+
+ {#if state.status === "download-pending"} + + {:else if state.status === "downloading"} + + {:else if state.status === "decryption-pending"} + + {:else if state.status === "decrypting"} + + {:else if state.status === "decrypted"} + + {:else if state.status === "error"} + + {/if}
-{/if} +
+

+ {info.name} +

+

+ {#if state.status === "download-pending"} + 다운로드를 기다리는 중 + {:else if state.status === "downloading"} + 전송됨 + {Math.floor((state.progress ?? 0) * 100)}% · + {formatNetworkSpeed((state.rate ?? 0) * 8)} + {:else if state.status === "decryption-pending"} + 복호화를 기다리는 중 + {:else if state.status === "decrypting"} + 복호화하는 중 + {:else if state.status === "decrypted"} + 다운로드 완료 + {:else if state.status === "error"} + 다운로드 실패 + {/if} +

+
+
diff --git a/src/routes/(fullscreen)/file/uploads/+page.svelte b/src/routes/(fullscreen)/file/uploads/+page.svelte index d456322..d280e7f 100644 --- a/src/routes/(fullscreen)/file/uploads/+page.svelte +++ b/src/routes/(fullscreen)/file/uploads/+page.svelte @@ -1,19 +1,12 @@ @@ -23,8 +16,8 @@
- {#each uploadingFiles as status} - + {#each uploadingFiles as file} + {/each}
diff --git a/src/routes/(fullscreen)/file/uploads/File.svelte b/src/routes/(fullscreen)/file/uploads/File.svelte index 2435240..4c620ee 100644 --- a/src/routes/(fullscreen)/file/uploads/File.svelte +++ b/src/routes/(fullscreen)/file/uploads/File.svelte @@ -1,6 +1,5 @@
- {#if $status.status === "encryption-pending"} + {#if state.status === "queued" || state.status === "encryption-pending"} - {:else if $status.status === "encrypting"} + {:else if state.status === "encrypting"} - {:else if $status.status === "upload-pending"} + {:else if state.status === "upload-pending"} - {:else if $status.status === "uploading"} + {:else if state.status === "uploading"} - {:else if $status.status === "uploaded"} + {:else if state.status === "uploaded"} - {:else if $status.status === "error"} + {:else if state.status === "error"} {/if}
-

- {$status.name} +

+ {state.name}

- {#if $status.status === "encryption-pending"} + {#if state.status === "queued"} + 대기 중 + {:else if state.status === "encryption-pending"} 준비 중 - {:else if $status.status === "encrypting"} + {:else if state.status === "encrypting"} 암호화하는 중 - {:else if $status.status === "upload-pending"} + {:else if state.status === "upload-pending"} 업로드를 기다리는 중 - {:else if $status.status === "uploading"} + {:else if state.status === "uploading"} 전송됨 - {Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)} - {:else if $status.status === "uploaded"} + {Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)} + {:else if state.status === "uploaded"} 업로드 완료 - {:else if $status.status === "error"} + {:else if state.status === "error"} 업로드 실패 {/if}

diff --git a/src/routes/(fullscreen)/gallery/+page.server.ts b/src/routes/(fullscreen)/gallery/+page.server.ts new file mode 100644 index 0000000..84d5819 --- /dev/null +++ b/src/routes/(fullscreen)/gallery/+page.server.ts @@ -0,0 +1,7 @@ +import { createCaller } from "$trpc/router.server"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + const files = await createCaller(event).file.list(); + return { files }; +}; diff --git a/src/routes/(fullscreen)/gallery/+page.svelte b/src/routes/(fullscreen)/gallery/+page.svelte index 1826c47..b27b15f 100644 --- a/src/routes/(fullscreen)/gallery/+page.svelte +++ b/src/routes/(fullscreen)/gallery/+page.svelte @@ -1,18 +1,57 @@ @@ -22,5 +61,37 @@ - goto(`/file/${id}`)} /> + + rows[index]!.type === "header" ? 28 : 181 + (rows[index]!.isLast ? 16 : 4)} + class="flex flex-grow flex-col" + > + {#snippet item(index)} + {@const row = rows[index]!} + {#if row.type === "header"} +

{row.label}

+ {:else} +
+ {#each row.files as file (file.id)} + goto(`/file/${file.id}?from=gallery`)} + /> + {/each} +
+ {/if} + {/snippet} + {#snippet placeholder()} +
+

+ {#if files.length === 0} + 업로드된 파일이 없어요. + {:else} + 사진 또는 동영상이 없어요. + {/if} +

+
+ {/snippet} +
diff --git a/src/routes/(fullscreen)/gallery/+page.ts b/src/routes/(fullscreen)/gallery/+page.ts deleted file mode 100644 index 1a241c5..0000000 --- a/src/routes/(fullscreen)/gallery/+page.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { trpc } from "$trpc/client"; -import type { PageLoad } from "./$types"; - -export const load: PageLoad = async ({ fetch }) => { - const files = await trpc(fetch).file.list.query(); - return { files }; -}; diff --git a/src/routes/(fullscreen)/settings/cache/+page.svelte b/src/routes/(fullscreen)/settings/cache/+page.svelte index cf8192d..1d6f0c4 100644 --- a/src/routes/(fullscreen)/settings/cache/+page.svelte +++ b/src/routes/(fullscreen)/settings/cache/+page.svelte @@ -1,42 +1,39 @@ @@ -55,8 +52,8 @@

캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.

- {#each fileCache as { index, fileInfo }} - + {#each fileCache as { index, info } (info.id)} + {/each}
diff --git a/src/routes/(fullscreen)/settings/cache/File.svelte b/src/routes/(fullscreen)/settings/cache/File.svelte index 581d144..2727381 100644 --- a/src/routes/(fullscreen)/settings/cache/File.svelte +++ b/src/routes/(fullscreen)/settings/cache/File.svelte @@ -1,7 +1,6 @@
- {#if $info} + {#if info.exists}
@@ -28,8 +27,8 @@
{/if}
- {#if $info} -

{$info.name}

+ {#if info.exists} +

{info.name}

{:else}

삭제된 파일

{/if} diff --git a/src/routes/(fullscreen)/settings/thumbnail/+page.server.ts b/src/routes/(fullscreen)/settings/thumbnail/+page.server.ts new file mode 100644 index 0000000..dc32e56 --- /dev/null +++ b/src/routes/(fullscreen)/settings/thumbnail/+page.server.ts @@ -0,0 +1,7 @@ +import { createCaller } from "$trpc/router.server"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + const files = await createCaller(event).file.listWithoutThumbnail(); + return { files }; +}; diff --git a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte index d9cd692..521a7b1 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte @@ -1,39 +1,52 @@ @@ -48,28 +61,30 @@ 저장된 썸네일 모두 삭제하기
- {#if persistentStates.files.length > 0} + {#if files.length > 0}

썸네일이 누락된 파일

- {persistentStates.files.length}개 파일의 썸네일이 존재하지 않아요. + {files.length}개 파일의 썸네일이 존재하지 않아요.

- {#each persistentStates.files as { info, status }} - goto(`/file/${id}`)} - onGenerateThumbnailClick={requestThumbnailGeneration} - /> + {#each files as { info, status } (info.id)} + {#if info.exists} + goto(`/file/${id}`)} + onGenerateThumbnailClick={requestThumbnailGeneration} + /> + {/if} {/each}
{/if}
- {#if persistentStates.files.length > 0} + {#if files.length > 0} diff --git a/src/routes/(fullscreen)/settings/thumbnail/+page.ts b/src/routes/(fullscreen)/settings/thumbnail/+page.ts deleted file mode 100644 index 4d5520c..0000000 --- a/src/routes/(fullscreen)/settings/thumbnail/+page.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { trpc } from "$trpc/client"; -import type { PageLoad } from "./$types"; - -export const load: PageLoad = async ({ fetch }) => { - const files = await trpc(fetch).file.listWithoutThumbnail.query(); - return { files }; -}; diff --git a/src/routes/(fullscreen)/settings/thumbnail/File.svelte b/src/routes/(fullscreen)/settings/thumbnail/File.svelte index 93c23ad..4440cf2 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/File.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/File.svelte @@ -10,37 +10,33 @@ -{#if $info} - onclick($info)} - actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined} - onActionButtonClick={() => onGenerateThumbnailClick($info)} - actionButtonClass="text-gray-800" - > - {@const subtext = - $generationStatus && $generationStatus !== "uploaded" - ? subtexts[$generationStatus] - : formatDateTime($info.createdAt ?? $info.lastModifiedAt)} - - -{/if} + onclick(info)} + actionButtonIcon={!status || status === "error" ? IconCamera : undefined} + onActionButtonClick={() => onGenerateThumbnailClick(info)} + actionButtonClass="text-gray-800" +> + {@const subtext = status + ? subtexts[status] + : formatDateTime(info.createdAt ?? info.lastModifiedAt)} + + diff --git a/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts b/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts deleted file mode 100644 index d8f288c..0000000 --- a/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { limitFunction } from "p-limit"; -import { get, writable, type Writable } from "svelte/store"; -import { encryptData } from "$lib/modules/crypto"; -import { storeFileThumbnailCache } from "$lib/modules/file"; -import type { FileInfo } from "$lib/modules/filesystem"; -import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail"; -import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file"; - -export type GenerationStatus = - | "queued" - | "generation-pending" - | "generating" - | "upload-pending" - | "uploading" - | "uploaded" - | "error"; - -interface File { - id: number; - info: Writable; - status?: Writable; -} - -const workingFiles = new Map>(); - -let queue: (() => void)[] = []; -let memoryUsage = 0; -const memoryLimit = 100 * 1024 * 1024; // 100 MiB - -export const persistentStates = $state({ - files: [] as File[], -}); - -export const getGenerationStatus = (fileId: number) => { - return workingFiles.get(fileId); -}; - -const generateThumbnail = limitFunction( - async ( - status: Writable, - fileBuffer: ArrayBuffer, - fileType: string, - dataKey: CryptoKey, - ) => { - status.set("generating"); - - const thumbnail = await doGenerateThumbnail(fileBuffer, fileType); - if (!thumbnail) return null; - - const thumbnailBuffer = await thumbnail.arrayBuffer(); - const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey); - status.set("upload-pending"); - return { plaintext: thumbnailBuffer, ...thumbnailEncrypted }; - }, - { concurrency: 4 }, -); - -const requestThumbnailUpload = limitFunction( - async ( - status: Writable, - fileId: number, - dataKeyVersion: Date, - thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: string }, - ) => { - status.set("uploading"); - - const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnail); - if (!res.ok) return false; - - status.set("uploaded"); - workingFiles.delete(fileId); - persistentStates.files = persistentStates.files.filter(({ id }) => id != fileId); - - storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended - return true; - }, - { concurrency: 4 }, -); - -const enqueue = async ( - status: Writable | undefined, - fileInfo: FileInfo, - priority = false, -) => { - if (status) { - status.set("queued"); - } else { - status = writable("queued"); - workingFiles.set(fileInfo.id, status); - persistentStates.files = persistentStates.files.map((file) => - file.id === fileInfo.id ? { ...file, status } : file, - ); - } - - let resolver; - const promise = new Promise((resolve) => { - resolver = resolve; - }); - - if (priority) { - queue = [resolver!, ...queue]; - } else { - queue.push(resolver!); - } - - await promise; -}; - -export const requestThumbnailGeneration = async (fileInfo: FileInfo) => { - let status = workingFiles.get(fileInfo.id); - if (status && get(status) !== "error") return; - - if (workingFiles.values().some((status) => get(status) !== "error")) { - await enqueue(status, fileInfo); - } - while (memoryUsage >= memoryLimit) { - await enqueue(status, fileInfo, true); - } - - if (status) { - status.set("generation-pending"); - } else { - status = writable("generation-pending"); - workingFiles.set(fileInfo.id, status); - persistentStates.files = persistentStates.files.map((file) => - file.id === fileInfo.id ? { ...file, status } : file, - ); - } - - let fileSize = 0; - try { - const file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey!); - fileSize = file.byteLength; - - memoryUsage += fileSize; - if (memoryUsage < memoryLimit) { - queue.shift()?.(); - } - - const thumbnail = await generateThumbnail( - status, - file, - fileInfo.contentType, - fileInfo.dataKey!, - ); - if ( - !thumbnail || - !(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail)) - ) { - status.set("error"); - } - } catch { - status.set("error"); - } finally { - memoryUsage -= fileSize; - queue.shift()?.(); - } -}; diff --git a/src/routes/(fullscreen)/settings/thumbnail/service.ts b/src/routes/(fullscreen)/settings/thumbnail/service.ts new file mode 100644 index 0000000..85226b0 --- /dev/null +++ b/src/routes/(fullscreen)/settings/thumbnail/service.ts @@ -0,0 +1,102 @@ +import { limitFunction } from "p-limit"; +import { SvelteMap } from "svelte/reactivity"; +import { encryptData } from "$lib/modules/crypto"; +import { storeFileThumbnailCache } from "$lib/modules/file"; +import type { FileInfo } from "$lib/modules/filesystem"; +import { Scheduler } from "$lib/modules/scheduler"; +import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail"; +import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file"; + +export type GenerationStatus = + | "queued" + | "generation-pending" + | "generating" + | "upload-pending" + | "uploading" + | "uploaded" + | "error"; + +const scheduler = new Scheduler(); +const statuses = new SvelteMap(); + +export const getThumbnailGenerationStatus = (fileId: number) => { + return statuses.get(fileId); +}; + +export const clearThumbnailGenerationStatuses = () => { + for (const [id, status] of statuses) { + if (status === "uploaded" || status === "error") { + statuses.delete(id); + } + } +}; + +const generateThumbnail = limitFunction( + async (fileId: number, fileBuffer: ArrayBuffer, fileType: string, dataKey: CryptoKey) => { + statuses.set(fileId, "generating"); + + const thumbnail = await doGenerateThumbnail(fileBuffer, fileType); + if (!thumbnail) return null; + + const thumbnailBuffer = await thumbnail.arrayBuffer(); + const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey); + statuses.set(fileId, "upload-pending"); + return { plaintext: thumbnailBuffer, ...thumbnailEncrypted }; + }, + { concurrency: 4 }, +); + +const requestThumbnailUpload = limitFunction( + async ( + fileId: number, + dataKeyVersion: Date, + thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: string }, + ) => { + statuses.set(fileId, "uploading"); + + const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnail); + if (!res.ok) return false; + statuses.set(fileId, "uploaded"); + storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended + return true; + }, + { concurrency: 4 }, +); + +export const requestThumbnailGeneration = async (fileInfo: FileInfo) => { + const status = statuses.get(fileInfo.id); + if (status) { + if (status !== "error") return; + } else { + statuses.set(fileInfo.id, "queued"); + } + + try { + let file: ArrayBuffer | undefined; + + await scheduler.schedule( + async () => { + statuses.set(fileInfo.id, "generation-pending"); + file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey?.key!); + return file.byteLength; + }, + async () => { + const thumbnail = await generateThumbnail( + fileInfo.id, + file!, + fileInfo.contentType, + fileInfo.dataKey?.key!, + ); + if ( + !thumbnail || + !(await requestThumbnailUpload(fileInfo.id, fileInfo.dataKey?.version!, thumbnail)) + ) { + statuses.set(fileInfo.id, "error"); + } + }, + ); + } catch (e) { + statuses.set(fileInfo.id, "error"); + throw e; + } +}; diff --git a/src/routes/(main)/category/[[id]]/+page.svelte b/src/routes/(main)/category/[[id]]/+page.svelte index 9b3e195..8672c9a 100644 --- a/src/routes/(main)/category/[[id]]/+page.svelte +++ b/src/routes/(main)/category/[[id]]/+page.svelte @@ -1,13 +1,16 @@ @@ -50,26 +70,56 @@ 카테고리
-{#if data.id !== "root"} - +{#if info?.id !== "root"} + {/if}
- {#if $info && isFileRecursive !== undefined} - goto(`/file/${id}?from=category`)} - 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={() => (isCategoryCreateModalOpen = true)} - onSubCategoryMenuClick={(subCategory) => { - context.selectedCategory = subCategory; - isCategoryMenuBottomSheetOpen = true; - }} - /> + {#if info?.exists} +
+
+ {#if info.id !== "root"} +

하위 카테고리

+ {/if} + goto(`/category/${id}`)} + onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} + onSubCategoryMenuClick={(subCategory) => { + context.selectedCategory = subCategory; + isCategoryMenuBottomSheetOpen = true; + }} + subCategoryMenuIcon={IconMoreVert} + /> +
+ {#if info.id !== "root"} +
+
+

파일

+ +

하위 카테고리의 파일

+
+
+ 48} itemGap={4}> + {#snippet item(index)} + {@const { details } = files[index]!} + goto(`/file/${id}?from=category`)} + onRemoveClick={!details.isRecursive + ? async ({ id }) => { + await requestFileRemovalFromCategory(id, data.id as number); + void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + } + : undefined} + /> + {/snippet} + {#snippet placeholder()} +

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

+ {/snippet} +
+
+ {/if} +
{/if}
@@ -77,7 +127,7 @@ bind:isOpen={isCategoryCreateModalOpen} onCreateClick={async (name: string) => { if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { - info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME return true; } return false; @@ -99,7 +149,7 @@ bind:isOpen={isCategoryRenameModalOpen} onRenameClick={async (newName: string) => { if (await requestCategoryRename(context.selectedCategory!, newName)) { - info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME return true; } return false; @@ -109,7 +159,7 @@ bind:isOpen={isCategoryDeleteModalOpen} onDeleteClick={async () => { if (await requestCategoryDeletion(context.selectedCategory!)) { - info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME return true; } return false; diff --git a/src/routes/(main)/category/[[id]]/File.svelte b/src/routes/(main)/category/[[id]]/File.svelte new file mode 100644 index 0000000..06ed8b3 --- /dev/null +++ b/src/routes/(main)/category/[[id]]/File.svelte @@ -0,0 +1,28 @@ + + + onclick(info)} + actionButtonIcon={onRemoveClick && IconClose} + onActionButtonClick={() => onRemoveClick?.(info)} +> + + diff --git a/src/routes/(main)/category/[[id]]/service.svelte.ts b/src/routes/(main)/category/[[id]]/service.svelte.ts index 18f68fd..b44d50d 100644 --- a/src/routes/(main)/category/[[id]]/service.svelte.ts +++ b/src/routes/(main)/category/[[id]]/service.svelte.ts @@ -5,6 +5,11 @@ import { trpc } from "$trpc/client"; export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category"; +export interface SelectedFile { + id: number; + name: string; +} + export const createContext = () => { const context = $state({ selectedCategory: undefined as SelectedCategory | undefined, @@ -17,12 +22,17 @@ export const useContext = () => { }; export const requestCategoryRename = async (category: SelectedCategory, newName: string) => { - const newNameEncrypted = await encryptString(newName, category.dataKey); + if (!category.dataKey) { + // TODO: Error Handling + return false; + } + + const newNameEncrypted = await encryptString(newName, category.dataKey.key); try { await trpc().category.rename.mutate({ id: category.id, - dekVersion: category.dataKeyVersion, + dekVersion: category.dataKey.version, name: newNameEncrypted.ciphertext, nameIv: newNameEncrypted.iv, }); diff --git a/src/routes/(main)/directory/[[id]]/+page.svelte b/src/routes/(main)/directory/[[id]]/+page.svelte index a4edf30..a0a4d53 100644 --- a/src/routes/(main)/directory/[[id]]/+page.svelte +++ b/src/routes/(main)/directory/[[id]]/+page.svelte @@ -1,12 +1,12 @@ @@ -91,28 +97,28 @@
{#if showTopBar} - + {/if} - {#if $info} + {#if info?.exists}
goto("/file/uploads")} /> goto("/file/downloads")} />
- {#key $info} + {#key info.id} goto(`/${type}/${id}`)} onEntryMenuClick={(entry) => { context.selectedEntry = entry; isEntryMenuBottomSheetOpen = true; }} - showParentEntry={isFromFilePage && $info.parentId !== undefined} + showParentEntry={isFromFilePage && info.parentId !== undefined} onParentClick={() => goto( - $info.parentId === "root" + info!.parentId === "root" ? "/directory?from=file" - : `/directory/${$info.parentId}?from=file`, + : `/directory/${info!.parentId}?from=file`, )} /> {/key} @@ -142,7 +148,7 @@ bind:isOpen={isDirectoryCreateModalOpen} onCreateClick={async (name) => { if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME return true; } return false; @@ -176,7 +182,7 @@ bind:isOpen={isEntryRenameModalOpen} onRenameClick={async (newName: string) => { if (await requestEntryRename(context.selectedEntry!, newName)) { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME return true; } return false; @@ -186,7 +192,7 @@ bind:isOpen={isEntryDeleteModalOpen} onDeleteClick={async () => { if (await requestEntryDeletion(context.selectedEntry!)) { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME return true; } return false; diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte index a3e975e..b1ac220 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte @@ -1,21 +1,9 @@ -{#if subDirectories.length + files.length > 0 || showParentEntry} -
- {#if showParentEntry} - - - - {/if} - {#each subDirectories as { info }} - - {/each} - {#each files as file} - {#if file.type === "file"} - +{#if entries.length > 0} + 56} itemGap={4} class="pb-[4.5rem]"> + {#snippet item(index)} + {@const entry = entries[index]!} + {#if entry.type === "parent"} + + + + {:else if entry.type === "directory"} + + {:else if entry.type === "file"} + {:else} - + {/if} - {/each} -
+ {/snippet} + {:else}

폴더가 비어 있어요.

diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte index 67d7e36..741bac3 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte @@ -1,66 +1,38 @@ -{#if $info} - - - -{/if} + action(onclick)} + actionButtonIcon={IconMoreVert} + onActionButtonClick={() => action(onOpenMenuClick)} +> + + diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte index 5454695..018a1e5 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte @@ -1,44 +1,29 @@ -{#if $info} - - - -{/if} + action(onclick)} + actionButtonIcon={IconMoreVert} + onActionButtonClick={() => action(onOpenMenuClick)} +> + + diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte index bf5e85a..30e6e20 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte @@ -1,38 +1,37 @@ -{#if isFileUploading($status.status)} -
-
- -
-
-

- {$status.name} -

-

- {#if $status.status === "encryption-pending"} - 준비 중 - {:else if $status.status === "encrypting"} - 암호화하는 중 - {:else if $status.status === "upload-pending"} - 업로드를 기다리는 중 - {:else if $status.status === "uploading"} - 전송됨 {Math.floor(($status.progress ?? 0) * 100)}% · - {formatNetworkSpeed(($status.rate ?? 0) * 8)} - {/if} -

-
+
+
+
-{/if} +
+

+ {state.name} +

+

+ {#if state.status === "queued"} + 대기 중 + {:else if state.status === "encryption-pending"} + 준비 중 + {:else if state.status === "encrypting"} + 암호화하는 중 + {:else if state.status === "upload-pending"} + 업로드를 기다리는 중 + {:else if state.status === "uploading"} + 전송됨 {Math.floor((state.progress ?? 0) * 100)}% · + {formatNetworkSpeed((state.rate ?? 0) * 8)} + {/if} +

+
+
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 d4b47f8..0000000 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts +++ /dev/null @@ -1 +0,0 @@ -export { requestFileThumbnailDownload } from "$lib/services/file"; diff --git a/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte b/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte index 18bb159..590cb8f 100644 --- a/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte +++ b/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte @@ -1,7 +1,5 @@ {#if downloadingFiles.length > 0} diff --git a/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte b/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte index 1ac40b3..578c368 100644 --- a/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte +++ b/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte @@ -1,7 +1,5 @@ {#if uploadingFiles.length > 0} diff --git a/src/routes/(main)/directory/[[id]]/service.svelte.ts b/src/routes/(main)/directory/[[id]]/service.svelte.ts index c94cc1e..f83bbaf 100644 --- a/src/routes/(main)/directory/[[id]]/service.svelte.ts +++ b/src/routes/(main)/directory/[[id]]/service.svelte.ts @@ -8,14 +8,14 @@ import { deleteFileThumbnailCache, uploadFile, } from "$lib/modules/file"; +import type { DataKey } from "$lib/modules/filesystem"; import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores"; import { trpc } from "$trpc/client"; export interface SelectedEntry { type: "directory" | "file"; id: number; - dataKey: CryptoKey; - dataKeyVersion: Date; + dataKey: DataKey | undefined; name: string; } @@ -97,20 +97,25 @@ export const requestFileUpload = async ( }; export const requestEntryRename = async (entry: SelectedEntry, newName: string) => { - const newNameEncrypted = await encryptString(newName, entry.dataKey); + if (!entry.dataKey) { + // TODO: Error Handling + return false; + } + + const newNameEncrypted = await encryptString(newName, entry.dataKey.key); try { if (entry.type === "directory") { await trpc().directory.rename.mutate({ id: entry.id, - dekVersion: entry.dataKeyVersion, + dekVersion: entry.dataKey.version, name: newNameEncrypted.ciphertext, nameIv: newNameEncrypted.iv, }); } else { await trpc().file.rename.mutate({ id: entry.id, - dekVersion: entry.dataKeyVersion, + dekVersion: entry.dataKey.version, name: newNameEncrypted.ciphertext, nameIv: newNameEncrypted.iv, }); diff --git a/src/routes/(main)/home/+page.svelte b/src/routes/(main)/home/+page.svelte index 3ee05bb..32e5d31 100644 --- a/src/routes/(main)/home/+page.svelte +++ b/src/routes/(main)/home/+page.svelte @@ -1,17 +1,14 @@ @@ -21,14 +18,16 @@

ArkVault

-
+
goto("/gallery")} class="w-full">

사진 및 동영상

-
- {#each mediaFiles as file} - goto(`/file/${id}`)} /> - {/each} -
+ {#if mediaFiles.length > 0} +
+ {#each mediaFiles as file (file.id)} + goto(`/file/${id}`)} /> + {/each} +
+ {/if}
diff --git a/src/routes/(main)/menu/+page.server.ts b/src/routes/(main)/menu/+page.server.ts new file mode 100644 index 0000000..d27816b --- /dev/null +++ b/src/routes/(main)/menu/+page.server.ts @@ -0,0 +1,7 @@ +import { createCaller } from "$trpc/router.server"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + const { nickname } = await createCaller(event).user.get(); + return { nickname }; +}; diff --git a/src/routes/(main)/menu/+page.ts b/src/routes/(main)/menu/+page.ts deleted file mode 100644 index 8842540..0000000 --- a/src/routes/(main)/menu/+page.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { trpc } from "$trpc/client"; -import type { PageLoad } from "./$types"; - -export const load: PageLoad = async ({ fetch }) => { - const { nickname } = await trpc(fetch).user.get.query(); - return { nickname }; -}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e4bca97..9aadffd 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,24 +1,14 @@