Author SHA1 Message Date
static ac6aaa18ca Merge pull request #20 from kmc7468/dev
v0.9.0
2026-01-18 13:30:15 +09:00
static 7b621d6e98 Merge pull request #19 from kmc7468/dev
v0.8.0
2026-01-13 00:29:14 +09:00
static 3906ec4371 Merge pull request #17 from kmc7468/dev
v0.7.0
2026-01-06 07:50:16 +09:00
static 90ac5ba4c3 Merge pull request #15 from kmc7468/dev
v0.6.0
2025-12-27 14:22:26 +09:00
static dfffa004ac Merge pull request #13 from kmc7468/dev
v0.5.1
2025-07-12 19:56:12 +09:00
static 0cd55a413d Merge pull request #12 from kmc7468/dev
v0.5.0
2025-07-12 06:01:08 +09:00
static 361d966a59 Merge pull request #10 from kmc7468/dev
v0.4.0
2025-01-30 21:06:50 +09:00
static aef43b8bfa Merge pull request #6 from kmc7468/dev
v0.3.0
2025-01-18 13:29:09 +09:00
static 7f128cccf6 Merge pull request #5 from kmc7468/dev
v0.2.0
2025-01-13 03:53:14 +09:00
static a198e5f6dc Merge pull request #2 from kmc7468/dev
v0.1.0
2025-01-09 06:24:31 +09:00
385 changed files with 10007 additions and 13650 deletions

No files matched your search

+1 -3
View File
@@ -12,6 +12,7 @@ node_modules
/data /data
/library /library
/thumbnails /thumbnails
/uploads
# OS # OS
.DS_Store .DS_Store
@@ -30,6 +31,3 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# E2E tests
/test/e2e/results
+1
View File
@@ -12,3 +12,4 @@ USER_CLIENT_CHALLENGE_EXPIRES=
SESSION_UPGRADE_CHALLENGE_EXPIRES= SESSION_UPGRADE_CHALLENGE_EXPIRES=
LIBRARY_PATH= LIBRARY_PATH=
THUMBNAILS_PATH= THUMBNAILS_PATH=
UPLOADS_PATH=
+1 -3
View File
@@ -10,6 +10,7 @@ node_modules
/data /data
/library /library
/thumbnails /thumbnails
/uploads
# OS # OS
.DS_Store .DS_Store
@@ -28,6 +29,3 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# E2E tests
/test/e2e/results
-1
View File
@@ -1,7 +1,6 @@
{ {
"printWidth": 100, "printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/app.css",
"overrides": [ "overrides": [
{ {
"files": "*.svelte", "files": "*.svelte",
+2 -2
View File
@@ -2,8 +2,8 @@
FROM node:22-alpine AS base FROM node:22-alpine AS base
WORKDIR /app WORKDIR /app
RUN npm install -g pnpm@12 RUN npm install -g pnpm@10
COPY pnpm-lock.yaml pnpm-workspace.yaml ./ COPY pnpm-lock.yaml .
# Build Stage # Build Stage
FROM base AS build FROM base AS build
+2
View File
@@ -9,6 +9,7 @@ services:
volumes: volumes:
- ./data/library:/app/data/library - ./data/library:/app/data/library
- ./data/thumbnails:/app/data/thumbnails - ./data/thumbnails:/app/data/thumbnails
- ./data/uploads:/app/data/uploads
environment: environment:
# ArkVault # ArkVault
- DATABASE_HOST=database - DATABASE_HOST=database
@@ -20,6 +21,7 @@ services:
- SESSION_UPGRADE_CHALLENGE_EXPIRES - SESSION_UPGRADE_CHALLENGE_EXPIRES
- LIBRARY_PATH=/app/data/library - LIBRARY_PATH=/app/data/library
- THUMBNAILS_PATH=/app/data/thumbnails - THUMBNAILS_PATH=/app/data/thumbnails
- UPLOADS_PATH=/app/data/uploads
# SvelteKit # SvelteKit
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For} - ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
- XFF_DEPTH=${TRUST_PROXY:-} - XFF_DEPTH=${TRUST_PROXY:-}
+2 -35
View File
@@ -1,6 +1,5 @@
import { includeIgnoreFile } from "@eslint/compat"; import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js"; import js from "@eslint/js";
import { loadConfig } from "@sveltejs/load-config";
import { defineConfig } from "eslint/config"; import { defineConfig } from "eslint/config";
import prettier from "eslint-config-prettier"; import prettier from "eslint-config-prettier";
import svelte from "eslint-plugin-svelte"; import svelte from "eslint-plugin-svelte";
@@ -8,31 +7,16 @@ import tailwind from "eslint-plugin-tailwindcss";
import globals from "globals"; import globals from "globals";
import ts from "typescript-eslint"; import ts from "typescript-eslint";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import svelteConfig from "./svelte.config.js";
const svelteConfig = (await loadConfig("./", { traverse: false }))?.config;
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url)); const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
// Restricts the direction of dependencies between modules
const defineImportBoundaries = (boundaries) =>
Object.entries(boundaries).map(([files, regex]) => ({
files: [files],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [{ regex, message: "Violates the dependency direction between modules." }],
},
],
},
}));
export default defineConfig( export default defineConfig(
includeIgnoreFile(gitignorePath), includeIgnoreFile(gitignorePath),
js.configs.recommended, js.configs.recommended,
...ts.configs.recommended, ...ts.configs.recommended,
...svelte.configs.recommended, ...svelte.configs.recommended,
tailwind.configs.recommended, ...tailwind.configs["flat/recommended"],
prettier, prettier,
...svelte.configs.prettier, ...svelte.configs.prettier,
{ {
@@ -42,27 +26,10 @@ export default defineConfig(
...globals.node, ...globals.node,
}, },
}, },
settings: {
tailwindcss: {
cssConfigPath: "./src/app.css",
},
},
rules: { rules: {
"no-undef": "off", "no-undef": "off",
}, },
}, },
...defineImportBoundaries({
// Pure code shared by the server, the client, and the service worker
"src/lib/shared/**": "^(#lib/(?!shared/)|#trpc/|#workers/|\\$app/|svelte($|/))",
"src/lib/server/**": "^(#lib/(client|features|ui)/|#trpc/)",
// Infrastructure of the browser
"src/lib/client/**": "^#lib/(server|features|ui)/",
// Domain-agnostic components
"src/lib/ui/**": "^(#lib/(server|client|features)/|#trpc/)",
"src/lib/features/**": "^#lib/server/",
"src/service-worker/**":
"^(#lib/(?!shared/|client/storage/|client/cache/file-cache\\.ts$)|#trpc/|#workers/)",
}),
{ {
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: { languageOptions: {
+11 -11
View File
@@ -1,17 +1,17 @@
import { defineConfig } from "kysely-ctl"; import { defineConfig } from "kysely-ctl";
import { createDialect } from "./src/lib/server/db/dialect.ts"; import { Pool } from "pg";
// Empty values (e.g. `DATABASE_HOST=` in .env) are treated as unset, as in src/env.ts
const optional = (name: string) => process.env[name] || undefined;
export default defineConfig({ export default defineConfig({
dialect: createDialect({ dialect: "pg",
host: optional("DATABASE_HOST"), dialectConfig: {
port: optional("DATABASE_PORT") ? Number(optional("DATABASE_PORT")) : undefined, pool: new Pool({
user: optional("DATABASE_USER"), host: process.env.DATABASE_HOST,
password: optional("DATABASE_PASSWORD"), port: process.env.DATABASE_PORT ? parseInt(process.env.DATABASE_PORT) : undefined,
name: optional("DATABASE_NAME"), user: process.env.DATABASE_USER,
}), password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
}),
},
migrations: { migrations: {
migrationFolder: "./src/lib/server/db/migrations", migrationFolder: "./src/lib/server/db/migrations",
}, },
+43 -55
View File
@@ -1,86 +1,74 @@
{ {
"name": "arkvault", "name": "arkvault",
"private": true, "private": true,
"version": "0.9.1", "version": "0.9.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"dev:remote": "__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=.ts.net vite dev --host 0.0.0.0",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && tsc --noEmit --project ./src/service-worker", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "bash test/e2e/run.sh",
"db:up": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d", "db:up": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d",
"db:down": "docker compose -f docker-compose.dev.yaml -p arkvault-dev down", "db:down": "docker compose -f docker-compose.dev.yaml -p arkvault-dev down",
"db:migrate": "kysely migrate" "db:migrate": "kysely migrate"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.1.1", "@eslint/compat": "^2.0.1",
"@eslint/js": "^10.0.1", "@eslint/js": "^9.39.2",
"@iconify-json/material-symbols": "^1.2.95", "@iconify-json/material-symbols": "^1.2.51",
"@noble/hashes": "^2.4.0", "@noble/hashes": "^2.0.1",
"@playwright/test": "1.64.0", "@sveltejs/adapter-node": "^5.5.1",
"@sveltejs/adapter-node": "^6.0.0", "@sveltejs/kit": "^2.49.5",
"@sveltejs/kit": "^3.0.1", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@sveltejs/load-config": "^0.2.2", "@tanstack/svelte-virtual": "^3.13.18",
"@sveltejs/vite-plugin-svelte": "^7.3.1", "@trpc/client": "^11.8.1",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/svelte-virtual": "^3.13.40",
"@testcontainers/postgresql": "^12.2.0",
"@trpc/client": "^11.19.0",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/ms": "^2.1.0", "@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.8", "@types/node-schedule": "^2.1.8",
"@types/pg": "^8.23.1", "@types/pg": "^8.16.0",
"axios": "^1.20.0", "autoprefixer": "^10.4.23",
"dexie": "^4.4.6", "axios": "^1.13.2",
"es-hangul": "^2.4.0", "dexie": "^4.2.1",
"eslint": "^10.12.0", "es-hangul": "^2.3.8",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.1", "eslint-plugin-svelte": "^3.14.0",
"eslint-plugin-tailwindcss": "^4.4.0", "eslint-plugin-tailwindcss": "^3.18.2",
"exifreader": "^4.48.0", "exifreader": "^4.36.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"globals": "^17.13.0", "globals": "^17.0.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"kysely-ctl": "^0.21.0", "kysely-ctl": "^0.20.0",
"lru-cache": "^11.5.3", "lru-cache": "^11.2.4",
"mime": "^4.1.0", "mime": "^4.1.0",
"p-limit": "^7.3.3", "p-limit": "^7.2.0",
"prettier": "^3.9.10", "prettier": "^3.8.0",
"prettier-plugin-svelte": "^4.1.1", "prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.8.1", "prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.57.2", "svelte": "^5.46.4",
"svelte-check": "^4.7.6", "svelte-check": "^4.3.5",
"tailwindcss": "^4.3.3", "tailwindcss": "^3.4.19",
"typescript": "^6.0.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.71.1", "typescript-eslint": "^8.53.0",
"unplugin-icons": "^24.0.0", "unplugin-icons": "^23.0.1",
"vite": "^8.3.4", "vite": "^7.3.1"
"vitest": "^5.0.3"
}, },
"dependencies": { "dependencies": {
"@trpc/server": "^11.19.0", "@trpc/server": "^11.8.1",
"argon2": "^0.45.1", "argon2": "^0.44.0",
"kysely": "^0.29.6", "kysely": "^0.28.9",
"ms": "^2.1.3", "ms": "^2.1.3",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"pg": "^8.23.1", "pg": "^8.17.1",
"superjson": "^2.2.6", "superjson": "^2.2.6",
"zod": "^4.6.5" "uuid": "^13.0.0",
"zod": "^4.3.5"
}, },
"engines": { "engines": {
"node": "^22.17.0", "node": "^22.0.0",
"pnpm": "^12.0.0" "pnpm": "^10.0.0"
},
"imports": {
"#lib/*": "./src/lib/*",
"#trpc/*": "./src/trpc/*",
"#workers/*": "./src/workers/*"
} }
} }
+1784 -2923
View File
File diff suppressed because it is too large. Load diff
-6
View File
@@ -1,6 +0,0 @@
allowBuilds:
argon2: true
cpu-features: false
exifreader: false
protobufjs: false
ssh2: false
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+3 -48
View File
@@ -1,48 +1,3 @@
@import "tailwindcss"; @import "tailwindcss/base";
@import "tailwindcss/components";
@theme { @import "tailwindcss/utilities";
--color-primary-100: #ffe3e3;
--color-primary-200: #ffc9c9;
--color-primary-300: #ffa8a8;
--color-primary-400: #ff8787;
--color-primary-500: #ff6b6b;
--color-primary-600: #fa5252;
--color-primary-700: #f03e3e;
--color-primary-800: #e03131;
--color-primary-900: #c92a2a;
--color-primary-000: #fff5f5;
--color-gray-100: #f1f3f5;
--color-gray-200: #e9ecef;
--color-gray-300: #dee2e6;
--color-gray-400: #ced4da;
--color-gray-500: #adb5bd;
--color-gray-600: #868e96;
--color-gray-700: #495057;
--color-gray-800: #343a40;
--color-gray-900: #212529;
--color-gray-000: #f8f9fa;
--font-sans:
"Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto,
"Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic",
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
-45
View File
@@ -1,45 +0,0 @@
import { building } from "$app/env";
import { defineEnvVars } from "@sveltejs/kit/env";
import ms, { type StringValue } from "ms";
// Empty values (e.g. `LIBRARY_PATH=` in .env) are treated as unset
const required = (value: string | undefined) => {
if (!value && !building) throw new Error("Required environment variable not set");
return value ?? "";
};
const optional = (value: string | undefined) => value || undefined;
const withDefault = (defaultValue: string) => (value: string | undefined) => value || defaultValue;
const duration = (defaultValue: StringValue) => (value: string | undefined) => {
const result = ms((value || defaultValue) as StringValue);
if (!Number.isFinite(result)) throw new Error(`Invalid duration: ${value}`);
return result;
};
export const variables = defineEnvVars({
NODE_ENV: { schema: withDefault("development") },
DATABASE_HOST: { schema: optional },
DATABASE_PORT: {
schema: (value) => {
if (!value) return undefined;
const port = Number(value);
if (!Number.isInteger(port)) throw new Error(`Invalid port: ${value}`);
return port;
},
},
DATABASE_USER: { schema: optional },
DATABASE_PASSWORD: { schema: required },
DATABASE_NAME: { schema: optional },
SESSION_SECRET: { schema: required },
SESSION_EXPIRES: { schema: duration("14d") },
USER_CLIENT_CHALLENGE_EXPIRES: { schema: duration("5m") },
SESSION_UPGRADE_CHALLENGE_EXPIRES: { schema: duration("5m") },
LIBRARY_PATH: { schema: withDefault("library") },
THUMBNAILS_PATH: { schema: withDefault("thumbnails") },
});
+37 -9
View File
@@ -1,8 +1,7 @@
import type { ClientInit } from "@sveltejs/kit/hooks"; import type { ClientInit } from "@sveltejs/kit";
import { cleanupFileCaches } from "#lib/client/cache/index.ts"; import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
import { keyring } from "#lib/client/keys/index.ts"; import { prepareFileCache } from "$lib/modules/file";
import { listenForServiceWorkerMessages } from "#lib/client/service-worker.ts"; import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
import { cleanupDanglingInfos } from "#lib/client/storage/indexed-db/index.ts";
const requestPersistentStorage = async () => { const requestPersistentStorage = async () => {
const isPersistent = await navigator.storage.persist(); const isPersistent = await navigator.storage.persist();
@@ -13,11 +12,40 @@ const requestPersistentStorage = async () => {
} }
}; };
export const init: ClientInit = async () => { const prepareClientKeyStore = async () => {
listenForServiceWorkerMessages(); const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([
getClientKey("encrypt"),
getClientKey("decrypt"),
getClientKey("sign"),
getClientKey("verify"),
]);
if (encryptKey && decryptKey && signKey && verifyKey) {
clientKeyStore.set({ encryptKey, decryptKey, signKey, verifyKey });
}
};
await Promise.all([requestPersistentStorage(), keyring.load()]); const prepareMasterKeyStore = async () => {
const masterKeys = await getMasterKeys();
if (masterKeys.length > 0) {
masterKeyStore.set(new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey])));
}
};
const prepareHmacSecretStore = async () => {
const hmacSecrets = await getHmacSecrets();
if (hmacSecrets.length > 0) {
hmacSecretStore.set(new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret])));
}
};
export const init: ClientInit = async () => {
await Promise.all([
requestPersistentStorage(),
prepareFileCache(),
prepareClientKeyStore(),
prepareMasterKeyStore(),
prepareHmacSecretStore(),
]);
cleanupDanglingInfos(); // Intended cleanupDanglingInfos(); // Intended
cleanupFileCaches().catch(console.error);
}; };
+14 -20
View File
@@ -1,30 +1,24 @@
import { sequence, type ServerInit } from "@sveltejs/kit/hooks"; import type { ServerInit } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import schedule from "node-schedule"; import schedule from "node-schedule";
import { migrateDB } from "#lib/server/db/kysely.ts"; import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
import { authenticate, setAgentInfo } from "#lib/server/middlewares/index.ts"; import { migrateDB } from "$lib/server/db/kysely";
import { import {
AuthService, cleanupExpiredSessions,
ClientService, cleanupExpiredSessionUpgradeChallenges,
FileDeletionService, } from "$lib/server/db/session";
UploadService, import { authenticate, setAgentInfo } from "$lib/server/middlewares";
} from "#lib/server/services/index.ts"; import { cleanupExpiredUploadSessions } from "$lib/server/services/upload";
export const init: ServerInit = async () => { export const init: ServerInit = async () => {
await migrateDB(); await migrateDB();
schedule.scheduleJob("0 * * * *", async () => { schedule.scheduleJob("0 * * * *", () => {
// Failures are logged and retried at the next run, so they don't crash the server cleanupExpiredUserClientChallenges();
const results = await Promise.allSettled([ cleanupExpiredSessions();
AuthService.cleanupExpiredSessions(), cleanupExpiredSessionUpgradeChallenges();
ClientService.cleanupExpiredChallenges(), cleanupExpiredUploadSessions();
UploadService.cleanupExpiredUploadSessions(),
]);
results.forEach((result) => {
if (result.status === "rejected") console.error(result.reason);
});
FileDeletionService.schedulePendingFileDeletions();
}); });
FileDeletionService.schedulePendingFileDeletions();
}; };
export const handle = sequence(setAgentInfo, authenticate); export const handle = sequence(setAgentInfo, authenticate);
-185
View File
@@ -1,185 +0,0 @@
import {
deleteFileCacheIndex,
getFileCacheIndex as getAllFileCacheIndexes,
getFileCacheIndexOf,
storeFileCacheIndex,
updateFileCacheRetrievedAt,
} from "#lib/client/storage/indexed-db/cache-index.ts";
import {
createWritable,
deleteDirectory,
deleteFile,
deleteFilesInDirectory,
getFile,
listDirectoryNames,
listFileNames,
moveFile,
} from "#lib/client/storage/opfs.ts";
import { CHUNK_SIZE, getChunkCount } from "#lib/shared/format/index.ts";
// Caches the plaintext of files in chunks, so that partially loaded files (e.g. videos which were
// partially played) can also be cached. Chunk `i` starts at `i * CHUNK_SIZE` of the plaintext,
// except that legacy files are cached as a single chunk. Used by both the pages and the service
// worker, so the index is always read from IndexedDB.
export interface FileCacheLayout {
totalSize: number;
chunkCount: number;
}
const getDirectoryPath = (fileId: number) => `/cache/file/${fileId}`;
const getChunkPath = (fileId: number, chunkIndex: number) =>
`${getDirectoryPath(fileId)}/${chunkIndex}`;
const getChunkSize = ({ totalSize, chunkCount }: FileCacheLayout, chunkIndex: number) => {
return chunkCount === 1 ? totalSize : Math.min(CHUNK_SIZE, totalSize - chunkIndex * CHUNK_SIZE);
};
export const getFileCacheIndex = async () => {
return await getAllFileCacheIndexes();
};
export const getCachedChunkIndexes = async (fileId: number) => {
const names = await listFileNames(getDirectoryPath(fileId));
return new Set(names.filter((name) => /^\d+$/.test(name)).map(Number));
};
export const readCachedChunk = async (fileId: number, chunkIndex: number) => {
return await getFile(getChunkPath(fileId, chunkIndex));
};
export const touchFileCache = async (fileId: number) => {
await updateFileCacheRetrievedAt(fileId, new Date());
};
const updateIndex = async (fileId: number, layout: FileCacheLayout) => {
const chunkIndexes = await getCachedChunkIndexes(fileId);
const size = chunkIndexes
.values()
.filter((chunkIndex) => chunkIndex < layout.chunkCount)
.reduce((size, chunkIndex) => size + getChunkSize(layout, chunkIndex), 0);
const now = new Date();
await storeFileCacheIndex({
fileId,
cachedAt: (await getFileCacheIndexOf(fileId))?.cachedAt ?? now,
lastRetrievedAt: now,
size,
totalSize: layout.totalSize,
chunkCount: layout.chunkCount,
isComplete: size === layout.totalSize,
});
};
// Writes of the same file are serialized so that the index isn't updated with a stale state
const writeQueues = new Map<number, Promise<unknown>>();
const enqueueWrite = <T>(fileId: number, write: () => Promise<T>) => {
const result = (writeQueues.get(fileId) ?? Promise.resolve()).catch(() => {}).then(write);
const settled = result.catch(() => {});
writeQueues.set(fileId, settled);
void settled.then(() => {
if (writeQueues.get(fileId) === settled) {
writeQueues.delete(fileId);
}
});
return result;
};
export const storeCachedChunk = (
fileId: number,
chunkIndex: number,
data: Uint8Array | Blob,
layout: FileCacheLayout,
) => enqueueWrite(fileId, () => writeChunk(fileId, chunkIndex, data, layout));
const writeChunk = async (
fileId: number,
chunkIndex: number,
data: Uint8Array | Blob,
layout: FileCacheLayout,
) => {
const path = getChunkPath(fileId, chunkIndex);
const tempPath = `${path}.${crypto.randomUUID()}.tmp`;
// Written to a temporary file first, so that incomplete chunks are never visible
const writable = await createWritable(tempPath);
try {
await writable.write(data as FileSystemWriteChunkType);
await writable.close();
await moveFile(tempPath, path);
} catch (e) {
await writable.abort().catch(() => {});
await deleteFile(tempPath).catch(() => {});
throw e;
}
await updateIndex(fileId, layout);
};
const UPLOAD_CACHE_DIRECTORY = "/cache/upload";
const STALE_UPLOAD_CACHE_AGE = 24 * 3600 * 1000; // 24 hours
// Caches the plaintext of a file while it is being uploaded. Chunks are written to a temporary
// directory, and moved to the cache once the id of the file is known.
export const createUploadFileCache = (totalSize: number) => {
const layout = { totalSize, chunkCount: getChunkCount(totalSize) };
// Prefixed with the creation time to find stale ones (e.g. the tab was closed while uploading)
const directoryPath = `${UPLOAD_CACHE_DIRECTORY}/${Date.now()}-${crypto.randomUUID()}`;
const writtenChunkIndexes = new Set<number>();
let isFailed = false;
const discard = async () => {
isFailed = true;
await deleteDirectory(directoryPath).catch(() => {});
};
return {
// Failing to cache shouldn't fail the upload, so errors are ignored
async writeChunk(chunkIndex: number, data: ArrayBuffer) {
if (isFailed) return;
try {
const writable = await createWritable(`${directoryPath}/${chunkIndex}`);
await writable.write(data);
await writable.close();
writtenChunkIndexes.add(chunkIndex);
} catch (e) {
console.error(e);
await discard();
}
},
async commit(fileId: number) {
if (isFailed || writtenChunkIndexes.size !== layout.chunkCount) {
await discard();
return;
}
await enqueueWrite(fileId, async () => {
for (const chunkIndex of writtenChunkIndexes) {
await moveFile(`${directoryPath}/${chunkIndex}`, getChunkPath(fileId, chunkIndex));
}
await updateIndex(fileId, layout);
}).finally(discard);
},
discard,
};
};
export const deleteFileCache = async (fileId: number) => {
await deleteFileCacheIndex(fileId);
await deleteDirectory(getDirectoryPath(fileId));
};
export const cleanupFileCaches = async () => {
// Files were cached as single files directly under `/cache` before
await deleteFilesInDirectory("/cache");
// Uploads might be in progress in other tabs, so only stale ones are deleted
for (const name of await listDirectoryNames(UPLOAD_CACHE_DIRECTORY)) {
const createdAt = parseInt(name, 10);
if (!(Date.now() - createdAt < STALE_UPLOAD_CACHE_AGE)) {
await deleteDirectory(`${UPLOAD_CACHE_DIRECTORY}/${name}`);
}
}
};
-2
View File
@@ -1,2 +0,0 @@
export * from "./file-cache.ts";
export * from "./thumbnail-cache.svelte.ts";
-86
View File
@@ -1,86 +0,0 @@
/* eslint-disable svelte/prefer-svelte-reactivity -- Lookup tables of reactive entries */
import { LRUCache } from "lru-cache";
import { deleteDirectory, deleteFile, readFile, writeFile } from "#lib/client/storage/opfs.ts";
import { decryptChunk, encodeToBase64 } from "#lib/shared/crypto/index.ts";
export interface Thumbnail {
url?: string;
}
const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => {
return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`;
};
// Thumbnails are reactive so that they can be updated in place
const loadedThumbnails = new LRUCache<number, Thumbnail>({ max: 100 });
const loadingThumbnails = new Map<number, Thumbnail>();
const fetchFromOpfs = async (fileId: number) => {
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
if (thumbnailBuffer) {
return getThumbnailUrl(thumbnailBuffer);
}
};
const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
if (!res.ok) return null;
const thumbnailBuffer = await decryptChunk(await res.arrayBuffer(), dataKey);
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
return getThumbnailUrl(thumbnailBuffer);
};
export const getFileThumbnail = (file: {
id: number;
contentType: string;
dataKey?: { key: CryptoKey };
}) => {
if (!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))) {
return undefined;
}
const thumbnail = loadedThumbnails.get(file.id) ?? loadingThumbnails.get(file.id);
if (thumbnail) return thumbnail;
const loadingThumbnail: Thumbnail = $state({});
loadingThumbnails.set(file.id, loadingThumbnail);
fetchFromOpfs(file.id)
.then((url) => url ?? (file.dataKey && fetchFromServer(file.id, file.dataKey.key)))
.then((url) => {
if (url) {
loadingThumbnail.url = url;
loadedThumbnails.set(file.id, loadingThumbnail);
}
})
.catch(console.error)
.finally(() => {
loadingThumbnails.delete(file.id);
});
return loadingThumbnail;
};
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
const url = getThumbnailUrl(thumbnailBuffer);
const thumbnail = loadedThumbnails.get(fileId) ?? loadingThumbnails.get(fileId);
if (thumbnail) {
thumbnail.url = url;
} else {
const newThumbnail: Thumbnail = $state({ url });
loadedThumbnails.set(fileId, newThumbnail);
}
};
export const deleteFileThumbnailCache = async (fileId: number) => {
loadedThumbnails.delete(fileId);
await deleteFile(`/thumbnail/file/${fileId}`);
};
export const deleteAllFileThumbnailCaches = async () => {
loadedThumbnails.clear();
await deleteDirectory(`/thumbnail/file`);
};
-5
View File
@@ -1,5 +0,0 @@
export * from "./generation.ts";
export * from "./keyring.svelte.ts";
export * from "./serialization.ts";
export * from "./sync.ts";
export * from "./types.ts";
-113
View File
@@ -1,113 +0,0 @@
import {
getClientKey,
getHmacSecrets,
getMasterKeys,
storeClientKey,
storeHmacSecrets,
storeMasterKeys,
} from "#lib/client/storage/indexed-db/index.ts";
import type { ClientKeys, HmacSecret, MasterKey } from "./types.ts";
const findLatestActive = <T extends { version: number; state: string }>(
entries: ReadonlyMap<number, T>,
) => {
return entries
.values()
.filter(({ state }) => state === "active")
.reduce<T | undefined>(
(latest, entry) => ((latest?.version ?? 0) > entry.version ? latest : entry),
undefined,
);
};
// Holds the keys of this device and the user. Keys are persisted in IndexedDB.
class Keyring {
clientKeys = $state.raw<ClientKeys | null>(null);
masterKeys = $state.raw<ReadonlyMap<number, MasterKey>>(new Map());
hmacSecrets = $state.raw<ReadonlyMap<number, HmacSecret>>(new Map());
async load() {
const [encryptKey, decryptKey, signKey, verifyKey, masterKeys, hmacSecrets] = await Promise.all(
[
getClientKey("encrypt"),
getClientKey("decrypt"),
getClientKey("sign"),
getClientKey("verify"),
getMasterKeys(),
getHmacSecrets(),
],
);
if (encryptKey && decryptKey && signKey && verifyKey) {
this.clientKeys = { encryptKey, decryptKey, signKey, verifyKey };
}
this.masterKeys = new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey]));
this.hmacSecrets = new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret]));
}
// Client keys are kept in memory only until they are registered and persisted
setClientKeys(clientKeys: ClientKeys) {
this.clientKeys = clientKeys;
}
async persistClientKeys() {
const clientKeys = this.requireClientKeys();
await Promise.all([
storeClientKey(clientKeys.encryptKey, "encrypt"),
storeClientKey(clientKeys.decryptKey, "decrypt"),
storeClientKey(clientKeys.signKey, "sign"),
storeClientKey(clientKeys.verifyKey, "verify"),
]);
}
async setMasterKeys(masterKeys: MasterKey[]) {
await storeMasterKeys(masterKeys);
this.masterKeys = new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey]));
}
async setHmacSecrets(hmacSecrets: HmacSecret[]) {
await storeHmacSecrets(hmacSecrets);
this.hmacSecrets = new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret]));
}
get hasMasterKeys() {
return this.masterKeys.size > 0;
}
get hasHmacSecrets() {
return this.hmacSecrets.size > 0;
}
requireClientKeys() {
if (!this.clientKeys) {
throw new Error("Client keys not found");
}
return this.clientKeys;
}
getMasterKey(version: number) {
const masterKey = this.masterKeys.get(version);
if (!masterKey) {
throw new Error(`Master key (version ${version}) not found`);
}
return masterKey;
}
// The master key used to encrypt new data
get activeMasterKey() {
const masterKey = findLatestActive(this.masterKeys);
if (!masterKey) {
throw new Error("Active master key not found");
}
return masterKey;
}
get activeHmacSecret() {
const hmacSecret = findLatestActive(this.hmacSecrets);
if (!hmacSecret) {
throw new Error("Active HMAC secret not found");
}
return hmacSecret;
}
}
export const keyring = new Keyring();
-182
View File
@@ -1,182 +0,0 @@
import {
decryptChallenge,
encodeToBase64,
exportRSAKeyToBase64,
signMasterKeyWrapped,
signMessageRSA,
unwrapHmacSecret,
unwrapMasterKey,
verifyMasterKeyWrapped,
} from "#lib/shared/crypto/index.ts";
import { isTRPCClientError, trpc } from "#trpc/client.ts";
import { keyring } from "./keyring.svelte.ts";
import type { ClientKeys } from "./types.ts";
// Synchronizes keys with the server
const exportPublicKeys = async ({ encryptKey, verifyKey }: ClientKeys) => ({
encPubKey: await exportRSAKeyToBase64(encryptKey),
sigPubKey: await exportRSAKeyToBase64(verifyKey),
});
const answerChallenge = async (challenge: string, { decryptKey, signKey }: ClientKeys) => {
const answer = await decryptChallenge(challenge, decryptKey);
return encodeToBase64(await signMessageRSA(answer, signKey));
};
export const requestSessionUpgrade = async (force = false) => {
const clientKeys = keyring.requireClientKeys();
let id, challenge;
try {
({ id, challenge } = await trpc.auth.upgrade.mutate(await exportPublicKeys(clientKeys)));
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "FORBIDDEN") {
return [false, "Unregistered client"] as const;
}
return [false] as const;
}
try {
await trpc.auth.verifyUpgrade.mutate({
id,
answerSig: await answerChallenge(challenge, clientKeys),
force,
});
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "CONFLICT") {
return [false, "Already logged in"] as const;
}
return [false] as const;
}
return [true] as const;
};
export const requestClientRegistration = async () => {
const clientKeys = keyring.requireClientKeys();
try {
const { id, challenge } = await trpc.client.register.mutate(await exportPublicKeys(clientKeys));
await trpc.client.verify.mutate({
id,
answerSig: await answerChallenge(challenge, clientKeys),
});
return true;
} catch (e) {
console.error(e);
return false;
}
};
export const requestClientRegistrationAndSessionUpgrade = async (force: boolean) => {
const [res, error] = await requestSessionUpgrade(force);
if (error === undefined) return [res] as const;
if (error === "Unregistered client" && !(await requestClientRegistration())) {
return [false] as const;
} else if (error === "Already logged in") {
return [false, force ? undefined : error] as const;
}
return [(await requestSessionUpgrade())[0]] as const;
};
export const registerInitialMasterKeyAndHmacSecret = async (
masterKeyWrapped: string,
hmacSecretWrapped: string,
) => {
const { signKey } = keyring.requireClientKeys();
try {
await trpc.mek.registerInitial.mutate({
mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
});
} catch (e) {
if (isTRPCClientError(e) && (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")) {
return true; // Already registered by another client
}
console.error(e);
return false;
}
try {
await trpc.hsk.registerInitial.mutate({
mekVersion: 1,
hsk: hmacSecretWrapped,
});
return true;
} catch (e) {
console.error(e);
return false;
}
};
export const downloadHmacSecrets = async () => {
let hmacSecretsWrapped;
try {
hmacSecretsWrapped = await trpc.hsk.list.query();
} catch (e) {
console.error(e);
return false;
}
const hmacSecrets = await Promise.all(
hmacSecretsWrapped.map(async ({ version, state, mekVersion, hsk: hmacSecretWrapped }) => {
const { hmacSecret } = await unwrapHmacSecret(
hmacSecretWrapped,
keyring.getMasterKey(mekVersion).key,
);
return { version, state, secret: hmacSecret };
}),
);
await keyring.setHmacSecrets(hmacSecrets);
return true;
};
// Downloads the master keys (and HMAC secrets) once this client has been approved
export const downloadMasterKeys = async () => {
const { decryptKey, verifyKey } = keyring.requireClientKeys();
let masterKeysWrapped;
try {
masterKeysWrapped = await trpc.mek.list.query();
} catch (e) {
console.error(e);
return false;
}
const masterKeys = await Promise.all(
masterKeysWrapped.map(
async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => {
const { masterKey } = await unwrapMasterKey(masterKeyWrapped, decryptKey);
return {
version,
state,
key: masterKey,
isValid: await verifyMasterKeyWrapped(
masterKeyWrapped,
version,
masterKeyWrappedSig,
verifyKey,
),
};
},
),
);
if (masterKeys.length === 0 || !masterKeys.every(({ isValid }) => isValid)) return false;
await keyring.setMasterKeys(
masterKeys.map(({ version, state, key }) => ({ version, state, key })),
);
await downloadHmacSecrets();
return true;
};
export const ensureHmacSecrets = async () => {
if (!keyring.hasHmacSecrets && !(await downloadHmacSecrets())) {
throw new Error("Failed to download HMAC secrets");
}
};
-18
View File
@@ -1,18 +0,0 @@
export interface ClientKeys {
encryptKey: CryptoKey;
decryptKey: CryptoKey;
signKey: CryptoKey;
verifyKey: CryptoKey;
}
export interface MasterKey {
version: number;
state: "active" | "retired";
key: CryptoKey;
}
export interface HmacSecret {
version: number;
state: "active";
secret: CryptoKey;
}
-52
View File
@@ -1,52 +0,0 @@
import ExifReader from "exifreader";
const isInteger = (value: number | undefined): value is number => Number.isInteger(value);
export const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
const exif = ExifReader.load(fileBuffer);
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
if (!dateTimeOriginal) return undefined;
const [date, time] = dateTimeOriginal.split(" ");
if (!date || !time) return undefined;
const [year, month, day] = date.split(":").map(Number);
const [hour, minute, second] = time.split(":").map(Number);
// Hours, minutes, and seconds may be 0, so they are checked differently
if (!year || !month || !day || !isInteger(hour) || !isInteger(minute) || !isInteger(second)) {
return undefined;
}
if (!offsetTimeOriginal) {
// No timezone information.. Assume local timezone
return new Date(year, month - 1, day, hour, minute, second);
}
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
return new Date(utcDate - offsetMs);
};
const EXIF_SEARCH_SIZE = 1024 * 1024; // 1 MiB
const MAX_FULL_READ_SIZE = 64 * 1024 * 1024; // 64 MiB
const tryExtractExifDateTime = (buffer: ArrayBuffer) => {
try {
return extractExifDateTime(buffer);
} catch {
return undefined;
}
};
// EXIF is usually at the beginning of the file, so the whole file is read only if not found
export const readExifDateTime = async (file: Blob) => {
const dateTime = tryExtractExifDateTime(await file.slice(0, EXIF_SEARCH_SIZE).arrayBuffer());
if (dateTime || file.size <= EXIF_SEARCH_SIZE || file.size > MAX_FULL_READ_SIZE) {
return dateTime;
}
return tryExtractExifDateTime(await file.arrayBuffer());
};
-5
View File
@@ -1,5 +0,0 @@
// Chromium can't decode HEIC images, so they are converted to be displayed
export const convertHeic = async (blob: Blob, toType: "image/png" | "image/jpeg") => {
const { default: heic2any } = await import("heic2any");
return (await heic2any({ blob, toType })) as Blob;
};
-80
View File
@@ -1,80 +0,0 @@
import { LRUCache } from "lru-cache";
import { handleDownloadProgress } from "#lib/client/transfer/download.svelte.ts";
import {
DECRYPTED_FILE_URL_PREFIX,
MAX_PREPARED_FILES,
type FileMetadata,
type ServiceWorkerMessage,
type ServiceWorkerResponse,
} from "#lib/shared/service-worker.ts";
const PREPARE_TIMEOUT_MS = 5000;
// The service worker may be terminated at any time, losing its in-memory metadata.
// Keep a copy here so that it can be re-requested on demand.
const preparedFiles = new LRUCache<number, FileMetadata>({ max: MAX_PREPARED_FILES });
let isListening = false;
export const listenForServiceWorkerMessages = () => {
if (isListening) return;
isListening = true;
navigator.serviceWorker.addEventListener(
"message",
(event: MessageEvent<ServiceWorkerResponse>) => {
switch (event.data.type) {
case "decryption-metadata-request":
event.ports[0]?.postMessage(preparedFiles.get(event.data.fileId) ?? null);
break;
case "download-progress":
handleDownloadProgress(event.data);
break;
}
},
);
};
const getServiceWorker = async () => {
const registration = await navigator.serviceWorker.ready;
const sw = registration.active;
if (!sw) {
throw new Error("Service worker not activated");
}
return sw;
};
export const prepareFileDecryption = async (id: number, metadata: FileMetadata) => {
preparedFiles.set(id, metadata);
listenForServiceWorkerMessages();
const sw = await getServiceWorker();
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Service worker timeout")),
PREPARE_TIMEOUT_MS,
);
const handler = (event: MessageEvent<ServiceWorkerResponse>) => {
if (event.data.type === "decryption-ready" && event.data.fileId === id) {
clearTimeout(timeout);
navigator.serviceWorker.removeEventListener("message", handler);
resolve();
}
};
navigator.serviceWorker.addEventListener("message", handler);
sw.postMessage({
type: "decryption-prepare",
fileId: id,
...metadata,
} satisfies ServiceWorkerMessage);
});
};
export const getDecryptedFileUrl = (id: number) => `${DECRYPTED_FILE_URL_PREFIX}${id}`;
// Lets the service worker download, decrypt, and save the file
export const saveDecryptedFile = (id: number, filename: string) => {
const link = document.createElement("a");
link.href = `${getDecryptedFileUrl(id)}?download=${encodeURIComponent(filename)}`;
link.click();
};
@@ -1,46 +0,0 @@
import { Dexie, type EntityTable } from "dexie";
export interface FileCacheIndex {
fileId: number;
cachedAt: Date;
lastRetrievedAt: Date;
// Files are cached in chunks, possibly partially (e.g. videos which were partially played)
size: number;
totalSize: number;
chunkCount: number;
isComplete: boolean;
}
const cacheIndex = new Dexie("cacheIndex") as Dexie & {
fileCache: EntityTable<FileCacheIndex, "fileId">;
};
// Files were cached as single files before version 2
cacheIndex
.version(2)
.stores({
fileCache: "fileId",
})
.upgrade(async (trx) => {
await trx.table("fileCache").clear();
});
export const getFileCacheIndex = async () => {
return await cacheIndex.fileCache.toArray();
};
export const getFileCacheIndexOf = async (fileId: number) => {
return await cacheIndex.fileCache.get(fileId);
};
export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => {
await cacheIndex.fileCache.put(fileCacheIndex);
};
export const updateFileCacheRetrievedAt = async (fileId: number, lastRetrievedAt: Date) => {
await cacheIndex.fileCache.update(fileId, { lastRetrievedAt });
};
export const deleteFileCacheIndex = async (fileId: number) => {
await cacheIndex.fileCache.delete(fileId);
};
@@ -1,3 +0,0 @@
export * from "./cache-index.ts";
export * from "./filesystem.ts";
export * from "./key-store.ts";
-130
View File
@@ -1,130 +0,0 @@
import { sha256 } from "@noble/hashes/sha2.js";
import axios from "axios";
import { encodeToBase64, encryptChunk } from "#lib/shared/crypto/index.ts";
import { CHUNK_SIZE, getChunkCount, getEncryptedSize } from "#lib/shared/format/index.ts";
import { BoundedQueue } from "#lib/utils/concurrency/index.ts";
import { createSpeedMeter } from "#lib/utils/speed-meter.ts";
interface UploadStats {
progress: number;
rate: number;
}
interface EncryptedChunk {
index: number;
data: ArrayBuffer;
}
const uploadEncryptedChunk = async (
uploadId: string,
chunkIndex: number,
encrypted: ArrayBuffer,
onChunkProgress: (chunkIndex: number, loaded: number) => void,
) => {
await axios.post(`/api/upload/${uploadId}/chunks/${chunkIndex + 1}`, encrypted, {
headers: { "Content-Type": "application/octet-stream" },
onUploadProgress(e) {
onChunkProgress(chunkIndex, e.loaded ?? 0);
},
});
onChunkProgress(chunkIndex, encrypted.byteLength);
};
export const uploadBlob = async (
uploadId: string,
blob: Blob,
dataKey: CryptoKey,
options?: {
concurrency?: number;
onProgress?: (s: UploadStats) => void;
// Receives each chunk of the plaintext before it is encrypted
onPlaintextChunk?: (chunkIndex: number, data: ArrayBuffer) => Promise<void>;
},
) => {
const onProgress = options?.onProgress;
const networkConcurrency = options?.concurrency ?? 4;
const maxQueueSize = 8;
const totalChunks = getChunkCount(blob.size);
const totalBytes = getEncryptedSize(blob.size);
const uploadedByChunk = new Array<number>(totalChunks).fill(0);
const speedMeter = createSpeedMeter(3000, 200);
const hash = sha256.create();
const emit = () => {
if (!onProgress) return;
const uploadedBytes = uploadedByChunk.reduce((a, b) => a + b, 0);
const rate = speedMeter(uploadedBytes);
const progress = Math.min(1, uploadedBytes / totalBytes);
onProgress({ progress, rate });
};
const onChunkProgress = (idx: number, loaded: number) => {
uploadedByChunk[idx] = loaded;
emit();
};
const queue = new BoundedQueue<EncryptedChunk>(maxQueueSize);
// Producer: encrypt chunks and push to queue
const encryptionProducer = async () => {
try {
for (let i = 0; i < totalChunks; i++) {
const chunk = await blob.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE).arrayBuffer();
await options?.onPlaintextChunk?.(i, chunk);
const data = await encryptChunk(chunk, dataKey);
hash.update(new Uint8Array(data));
await queue.push({ index: i, data });
}
} finally {
queue.close();
}
};
// Consumer: upload chunks from queue with concurrency limit
const uploadConsumer = async () => {
const activeTasks = new Set<Promise<void>>();
let failure: { reason: unknown } | undefined;
try {
while (!failure) {
const item = await queue.pop();
if (item === null) break;
const task: Promise<void> = uploadEncryptedChunk(
uploadId,
item.index,
item.data,
onChunkProgress,
)
.catch((reason) => {
failure ??= { reason };
})
.finally(() => activeTasks.delete(task));
activeTasks.add(task);
if (activeTasks.size >= networkConcurrency) {
await Promise.race(activeTasks);
}
}
await Promise.all(activeTasks);
if (failure) throw failure.reason;
} finally {
queue.close(); // Stops the producer if the upload failed
}
};
// Run producer and consumer concurrently
const [encryption, upload] = await Promise.allSettled([encryptionProducer(), uploadConsumer()]);
// The upload error takes precedence, as the producer fails due to the closed queue then
if (upload.status === "rejected") throw upload.reason;
if (encryption.status === "rejected") throw encryption.reason;
onProgress?.({ progress: 1, rate: speedMeter() });
return { encContentHash: encodeToBase64(hash.digest()) };
};
@@ -1,55 +0,0 @@
import type { DownloadProgressMessage } from "#lib/shared/service-worker.ts";
import { createSpeedMeter } from "#lib/utils/speed-meter.ts";
// Files are downloaded and decrypted by the service worker, which reports the progress
export interface FileDownloadState {
id: number;
status: "downloading" | "completed" | "error";
progress?: number;
rate?: number;
}
let downloadingFiles: FileDownloadState[] = $state([]);
const speedMeters = new WeakMap<FileDownloadState, ReturnType<typeof createSpeedMeter>>();
export const isFileDownloading = (status: FileDownloadState["status"]) => {
return status === "downloading";
};
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));
};
export const handleDownloadProgress = ({
fileId,
status,
loaded,
total,
}: DownloadProgressMessage) => {
let state = getFileDownloadState(fileId);
if (!state) {
if (status !== "downloading") return;
downloadingFiles.push({ id: fileId, status });
state = downloadingFiles.at(-1)!;
speedMeters.set(state, createSpeedMeter());
}
if (status === "canceled") {
downloadingFiles = downloadingFiles.filter((file) => file !== state);
return;
}
state.status = status;
state.progress = total > 0 ? loaded / total : 1;
state.rate = speedMeters.get(state)?.(loaded);
};
-2
View File
@@ -1,2 +0,0 @@
export * from "./download.svelte.ts";
export * from "./upload.svelte.ts";
@@ -2,7 +2,7 @@
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements"; import type { ClassValue } from "svelte/elements";
import { fade, fly } from "svelte/transition"; import { fade, fly } from "svelte/transition";
import AdaptiveDiv from "../layout/AdaptiveDiv.svelte"; import { AdaptiveDiv } from "$lib/components/atoms";
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -21,14 +21,17 @@
onclick={onclose || (() => (isOpen = false))} onclick={onclose || (() => (isOpen = false))}
class="fixed inset-0 z-10 flex items-end justify-center" class="fixed inset-0 z-10 flex items-end justify-center"
> >
<div class="absolute inset-0 bg-black/50" transition:fade|global={{ duration: 100 }}></div> <div
class="absolute inset-0 bg-black bg-opacity-50"
transition:fade|global={{ duration: 100 }}
></div>
<AdaptiveDiv class="z-10 w-full"> <AdaptiveDiv class="z-10 w-full">
<div <div
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
class="flex max-h-[70vh] min-h-[30vh] flex-col rounded-t-2xl bg-white" class="flex max-h-[70vh] min-h-[30vh] flex-col rounded-t-2xl bg-white"
transition:fly|global={{ y: 100, duration: 200 }} transition:fly|global={{ y: 100, duration: 200 }}
> >
<div class={["grow overflow-y-auto", className]}> <div class={["flex-grow overflow-y-auto", className]}>
{@render children()} {@render children()}
</div> </div>
</div> </div>
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements"; import type { ClassValue } from "svelte/elements";
import { afterPress } from "../press.ts";
import IconClose from "~icons/material-symbols/close"; import IconClose from "~icons/material-symbols/close";
@@ -27,7 +26,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
onclick={afterPress(onclick)} onclick={onclick && (() => setTimeout(onclick, 100))}
class={[ class={[
"inline-flex cursor-pointer items-center gap-x-1 rounded-lg px-3 py-1.5 text-sm font-medium transition active:scale-95", "inline-flex cursor-pointer items-center gap-x-1 rounded-lg px-3 py-1.5 text-sm font-medium transition active:scale-95",
selected selected
@@ -43,7 +42,9 @@
<button <button
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
afterPress(onRemoveClick)?.(); if (onRemoveClick) {
setTimeout(onRemoveClick, 100);
}
}} }}
> >
<IconClose /> <IconClose />
@@ -2,7 +2,7 @@
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements"; import type { ClassValue } from "svelte/elements";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import AdaptiveDiv from "../layout/AdaptiveDiv.svelte"; import { AdaptiveDiv } from "$lib/components/atoms";
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -19,7 +19,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
onclick={onclose || (() => (isOpen = false))} onclick={onclose || (() => (isOpen = false))}
class="fixed inset-0 z-10 bg-black/50" class="fixed inset-0 z-10 bg-black bg-opacity-50"
transition:fade|global={{ duration: 100 }} transition:fade|global={{ duration: 100 }}
> >
<AdaptiveDiv class="flex h-full items-center justify-center px-4"> <AdaptiveDiv class="flex h-full items-center justify-center px-4">
@@ -55,7 +55,7 @@
<div style:height="{$virtualizer.getTotalSize()}px"> <div style:height="{$virtualizer.getTotalSize()}px">
{#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)} {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)}
<div <div
class="absolute top-0 left-0 w-full" class="absolute left-0 top-0 w-full"
style:transform="translateY({virtualItem.start - scrollMargin}px)" style:transform="translateY({virtualItem.start - scrollMargin}px)"
data-index={virtualItem.index} data-index={virtualItem.index}
use:measureItem use:measureItem
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Component, Snippet } from "svelte"; import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements"; import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { afterPress } from "../press.ts";
interface Props { interface Props {
actionButtonClass?: ClassValue; actionButtonClass?: ClassValue;
@@ -24,19 +23,25 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div data-entry-container onclick={afterPress(onclick)} class={["rounded-xl", className]}> <div
<div data-entry-content class="flex h-full items-center gap-x-4 p-2 transition"> id="container"
<div class="grow overflow-x-hidden"> onclick={onclick && (() => setTimeout(onclick, 100))}
class={["rounded-xl", className]}
>
<div id="children" class="flex h-full items-center gap-x-4 p-2 transition">
<div class="flex-grow overflow-x-hidden">
{@render children()} {@render children()}
</div> </div>
{#if ActionButtonIcon} {#if ActionButtonIcon}
<button <button
data-action-button id="action-button"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
afterPress(onActionButtonClick)?.(); if (onActionButtonClick) {
setTimeout(onActionButtonClick, 100);
}
}} }}
class={["shrink-0 rounded-full p-1 text-lg active:bg-gray-100", actionButtonClassName]} class={["flex-shrink-0 rounded-full p-1 text-lg active:bg-gray-100", actionButtonClassName]}
> >
<ActionButtonIcon /> <ActionButtonIcon />
</button> </button>
@@ -45,12 +50,10 @@
</div> </div>
<style lang="postcss"> <style lang="postcss">
@reference "../../../app.css"; #container:active:not(:has(#action-button:active)) {
[data-entry-container]:active:not(:has([data-action-button]:active)) {
@apply bg-gray-100; @apply bg-gray-100;
} }
[data-entry-content]:active:not(:has([data-action-button]:active)) { #children:active:not(:has(#action-button:active)) {
@apply scale-95; @apply scale-95;
} }
</style> </style>
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements"; import type { ClassValue } from "svelte/elements";
import { afterPress } from "../press.ts";
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -27,7 +26,7 @@
</script> </script>
<button <button
onclick={afterPress(onclick)} onclick={onclick && (() => setTimeout(onclick, 100))}
class={[ class={[
"h-12 min-w-fit rounded-xl p-3 font-medium transition active:scale-95", "h-12 min-w-fit rounded-xl p-3 font-medium transition active:scale-95",
bgColor, bgColor,
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements"; import type { ClassValue } from "svelte/elements";
import { afterPress } from "../press.ts";
import IconChevronRight from "~icons/material-symbols/chevron-right"; import IconChevronRight from "~icons/material-symbols/chevron-right";
@@ -14,11 +13,14 @@
let { children, class: className, onclick }: Props = $props(); let { children, class: className, onclick }: Props = $props();
</script> </script>
<button onclick={afterPress(onclick)} class={["rounded-xl active:bg-gray-100", className]}> <button
onclick={onclick && (() => setTimeout(onclick, 100))}
class={["rounded-xl active:bg-gray-100", className]}
>
<div class="flex h-full items-center gap-x-4 p-2 transition active:scale-95"> <div class="flex h-full items-center gap-x-4 p-2 transition active:scale-95">
<div class="grow"> <div class="flex-grow">
{@render children()} {@render children()}
</div> </div>
<IconChevronRight class="shrink-0 text-xl text-gray-800" /> <IconChevronRight class="flex-shrink-0 text-xl text-gray-800" />
</div> </div>
</button> </button>
@@ -0,0 +1,34 @@
<script lang="ts">
import { getFileThumbnail } from "$lib/modules/file";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import IconFavorite from "~icons/material-symbols/favorite";
interface Props {
info: SummarizedFileInfo;
onclick?: (file: SummarizedFileInfo) => void;
}
let { info, onclick }: Props = $props();
let thumbnail = $derived(getFileThumbnail(info));
</script>
<button
onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
class="relative aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
>
{#if $thumbnail}
<img src={$thumbnail} alt={info.name} class="h-full w-full object-cover" />
{:else}
<div class="h-full w-full bg-gray-100"></div>
{/if}
{#if info.isFavorite}
<div class={["absolute bottom-0.5 right-0.5"]}>
<IconFavorite
class="text-sm text-red-500"
style="filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px white)"
/>
</div>
{/if}
</button>
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Component } from "svelte"; import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements"; import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import AdaptiveDiv from "../layout/AdaptiveDiv.svelte"; import { AdaptiveDiv } from "$lib/components/atoms";
import { afterPress } from "../press.ts";
interface Props { interface Props {
class: ClassValue; class: ClassValue;
@@ -16,9 +15,9 @@
<div class="pointer-events-none fixed inset-0"> <div class="pointer-events-none fixed inset-0">
<AdaptiveDiv class="relative h-full"> <AdaptiveDiv class="relative h-full">
<button <button
onclick={afterPress(onclick)} onclick={onclick && (() => setTimeout(onclick, 100))}
class={[ class={[
"pointer-events-auto absolute flex size-14 items-center justify-center rounded-full bg-gray-300 text-xl shadow-lg transition active:scale-95 active:bg-gray-400", "pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full bg-gray-300 text-xl shadow-lg transition active:scale-95 active:bg-gray-400",
className, className,
]} ]}
> >
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { afterPress } from "../press.ts";
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -11,7 +10,7 @@
</script> </script>
<button <button
onclick={afterPress(onclick)} onclick={onclick && (() => setTimeout(onclick, 100))}
class="text-sm font-medium text-gray-800 underline underline-offset-2 active:rounded-xl active:bg-gray-100" class="text-sm font-medium text-gray-800 underline underline-offset-2 active:rounded-xl active:bg-gray-100"
> >
<div class="h-full p-1 transition active:scale-95"> <div class="h-full p-1 transition active:scale-95">
@@ -0,0 +1,6 @@
export { default as ActionEntryButton } from "./ActionEntryButton.svelte";
export { default as Button } from "./Button.svelte";
export { default as EntryButton } from "./EntryButton.svelte";
export { default as FileThumbnailButton } from "./FileThumbnailButton.svelte";
export { default as FloatingButton } from "./FloatingButton.svelte";
export { default as TextButton } from "./TextButton.svelte";
@@ -10,6 +10,6 @@
let { children, class: className }: Props = $props(); let { children, class: className }: Props = $props();
</script> </script>
<div class={["mx-auto max-w-(--breakpoint-md)", className]}> <div class={["mx-auto max-w-screen-md", className]}>
{@render children()} {@render children()}
</div> </div>
@@ -10,6 +10,6 @@
let { children, class: className }: Props = $props(); let { children, class: className }: Props = $props();
</script> </script>
<div class={["flex grow flex-col justify-between px-4", className]}> <div class={["flex flex-grow flex-col justify-between px-4", className]}>
{@render children()} {@render children()}
</div> </div>
+3
View File
@@ -0,0 +1,3 @@
export { default as AdaptiveDiv } from "./AdaptiveDiv.svelte";
export { default as BottomDiv } from "./BottomDiv.svelte";
export { default as FullscreenDiv } from "./FullscreenDiv.svelte";
+7
View File
@@ -0,0 +1,7 @@
export { default as BottomSheet } from "./BottomSheet.svelte";
export * from "./buttons";
export { default as Chip } from "./Chip.svelte";
export * from "./divs";
export * from "./inputs";
export { default as Modal } from "./Modal.svelte";
export { default as RowVirtualizer } from "./RowVirtualizer.svelte";
@@ -17,11 +17,11 @@
bind:value bind:value
{type} {type}
placeholder="" placeholder=""
class="w-full border-b-2 border-gray-300 py-1 text-xl outline-hidden transition duration-300 ease-in-out" class="w-full border-b-2 border-gray-300 py-1 text-xl outline-none transition duration-300 ease-in-out"
/> />
<!-- svelte-ignore a11y_label_has_associated_control --> <!-- svelte-ignore a11y_label_has_associated_control -->
<label <label
class="pointer-events-none absolute top-1/2 left-0 -translate-y-1/2 transform text-xl text-gray-400 transition-all duration-300 ease-in-out" class="pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 transform text-xl text-gray-400 transition-all duration-300 ease-in-out"
> >
{placeholder} {placeholder}
</label> </label>
@@ -29,8 +29,6 @@
</div> </div>
<style lang="postcss"> <style lang="postcss">
@reference "../../../app.css";
input:focus, input:focus,
input:not(:placeholder-shown) { input:not(:placeholder-shown) {
@apply border-primary-300; @apply border-primary-300;
+2
View File
@@ -0,0 +1,2 @@
export { default as CheckBox } from "./CheckBox.svelte";
export { default as TextInput } from "./TextInput.svelte";
@@ -4,8 +4,7 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import Button from "../buttons/Button.svelte"; import { Button, Modal } from "$lib/components/atoms";
import Modal from "./Modal.svelte";
interface Props { interface Props {
cancelText?: string; cancelText?: string;
@@ -1,5 +1,5 @@
<script module lang="ts"> <script module lang="ts">
import type { DataKey, SubCategoryInfo } from "#lib/features/filesystem/index.ts"; import type { DataKey } from "$lib/modules/filesystem";
export interface SelectedCategory { export interface SelectedCategory {
id: number; id: number;
@@ -11,8 +11,10 @@
<script lang="ts"> <script lang="ts">
import type { Component } from "svelte"; import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements"; import type { SvelteHTMLElements } from "svelte/elements";
import { ActionEntryButton, CategoryLabel } from "#lib/ui/index.ts"; import { ActionEntryButton } from "$lib/components/atoms";
import { sortEntries } from "#lib/utils/index.ts"; import { CategoryLabel } from "$lib/components/molecules";
import type { SubCategoryInfo } from "$lib/modules/filesystem";
import { sortEntries } from "$lib/utils";
interface Props { interface Props {
categories: SubCategoryInfo[]; categories: SubCategoryInfo[];
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Component, Snippet } from "svelte"; import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements"; import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import IconLabel from "../labels/IconLabel.svelte"; import { EntryButton } from "$lib/components/atoms";
import EntryButton from "./EntryButton.svelte"; import { IconLabel } from "$lib/components/molecules";
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Component } from "svelte"; import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements"; import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { Categories, type SelectedCategory } from "#lib/features/filesystem/components/index.ts"; import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules";
import type { CategoryInfo } from "#lib/features/filesystem/index.ts"; import type { CategoryInfo } from "$lib/modules/filesystem";
import { IconEntryButton } from "#lib/ui/index.ts";
import IconAddCircle from "~icons/material-symbols/add-circle"; import IconAddCircle from "~icons/material-symbols/add-circle";
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Component, Snippet } from "svelte"; import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements"; import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import TitleLabel from "../labels/TitleLabel.svelte"; import { TitleLabel } from "$lib/components/molecules";
interface Props { interface Props {
children?: Snippet; children?: Snippet;
@@ -21,20 +21,20 @@
className, className,
]} ]}
> >
<div class="w-[2.3rem] shrink-0"> <div class="w-[2.3rem] flex-shrink-0">
{#if showBackButton} {#if showBackButton}
<button <button
onclick={onBackClick ?? (() => history.back())} onclick={onBackClick ?? (() => history.back())}
class="w-full rounded-full p-1 text-2xl active:bg-black/4" class="w-full rounded-full p-1 text-2xl active:bg-black active:bg-opacity-[0.04]"
> >
<IconArrowBack /> <IconArrowBack />
</button> </button>
{/if} {/if}
</div> </div>
{#if title} {#if title}
<p class="grow truncate text-center text-lg font-semibold">{title}</p> <p class="flex-grow truncate text-center text-lg font-semibold">{title}</p>
{/if} {/if}
<div class="w-[2.3rem] shrink-0"> <div class="w-[2.3rem] flex-shrink-0">
{#if children} {#if children}
{@render children()} {@render children()}
{/if} {/if}
+9
View File
@@ -0,0 +1,9 @@
export * from "./ActionModal.svelte";
export { default as ActionModal } from "./ActionModal.svelte";
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";
export { default as TitledDiv } from "./TitledDiv.svelte";
export { default as TopBar } from "./TopBar.svelte";
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { ClassValue } from "svelte/elements"; import type { ClassValue } from "svelte/elements";
import IconLabel from "./IconLabel.svelte"; import { IconLabel } from "$lib/components/molecules";
import IconCategory from "~icons/material-symbols/category"; import IconCategory from "~icons/material-symbols/category";
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { ClassValue } from "svelte/elements"; import type { ClassValue } from "svelte/elements";
import IconLabel from "./IconLabel.svelte"; import { IconLabel } from "$lib/components/molecules";
import IconFolder from "~icons/material-symbols/folder"; import IconFolder from "~icons/material-symbols/folder";
import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload"; import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload";
@@ -29,14 +29,9 @@
</script> </script>
{#snippet iconSnippet()} {#snippet iconSnippet()}
<div class="relative flex size-10 items-center justify-center text-xl"> <div class="relative flex h-10 w-10 items-center justify-center text-xl">
{#if thumbnail} {#if thumbnail}
<img <img src={thumbnail} alt={name} loading="lazy" class="aspect-square rounded object-cover" />
src={thumbnail}
alt={name}
loading="lazy"
class="aspect-square rounded-sm object-cover"
/>
{:else if type === "directory"} {:else if type === "directory"}
<IconFolder /> <IconFolder />
{:else if type === "parent-directory"} {:else if type === "parent-directory"}
@@ -45,7 +40,7 @@
<IconDraft class="text-blue-400" /> <IconDraft class="text-blue-400" />
{/if} {/if}
{#if isFavorite} {#if isFavorite}
<div class={["absolute right-0 bottom-0", !thumbnail && "rounded-full bg-white p-0.5"]}> <div class={["absolute bottom-0 right-0", !thumbnail && "rounded-full bg-white p-0.5"]}>
<IconFavorite <IconFavorite
class="text-xs text-red-500" class="text-xs text-red-500"
style="filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px white)" style="filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px white)"
@@ -25,15 +25,15 @@
<div class={["flex items-center gap-x-4", className]}> <div class={["flex items-center gap-x-4", className]}>
{#if iconSnippet} {#if iconSnippet}
<div class={["shrink-0", iconClassName]}> <div class={["flex-shrink-0", iconClassName]}>
{@render iconSnippet()} {@render iconSnippet()}
</div> </div>
{:else if Icon} {:else if Icon}
<div class={["shrink-0 text-lg", iconClassName]}> <div class={["flex-shrink-0 text-lg", iconClassName]}>
<Icon /> <Icon />
</div> </div>
{/if} {/if}
<div class="flex grow flex-col overflow-x-hidden text-left"> <div class="flex flex-grow flex-col overflow-x-hidden text-left">
<p class={["truncate font-medium", textClassName]}> <p class={["truncate font-medium", textClassName]}>
{@render children()} {@render children()}
</p> </p>
@@ -0,0 +1,4 @@
export { default as CategoryLabel } from "./CategoryLabel.svelte";
export { default as DirectoryEntryLabel } from "./DirectoryEntryLabel.svelte";
export { default as IconLabel } from "./IconLabel.svelte";
export { default as TitleLabel } from "./TitleLabel.svelte";
+1
View File
@@ -0,0 +1 @@
export * from "./modals";
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { TextInputModal } from "#lib/ui/index.ts"; import { TextInputModal } from "$lib/components/organisms";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ActionModal } from "#lib/ui/index.ts"; import { ActionModal } from "$lib/components/molecules";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import TextInputModal from "./TextInputModal.svelte"; import { TextInputModal } from "$lib/components/organisms";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import TextInput from "../inputs/TextInput.svelte"; import { TextInput } from "$lib/components/atoms";
import ActionModal, { type ConfirmHandler } from "./ActionModal.svelte"; import { ActionModal, type ConfirmHandler } from "$lib/components/molecules";
interface Props { interface Props {
defaultValue?: string; defaultValue?: string;
@@ -0,0 +1,4 @@
export { default as CategoryCreateModal } from "./CategoryCreateModal.svelte";
export { default as ForceLoginModal } from "./ForceLoginModal.svelte";
export { default as RenameModal } from "./RenameModal.svelte";
export { default as TextInputModal } from "./TextInputModal.svelte";
+2
View File
@@ -0,0 +1,2 @@
export * from "./serviceWorker";
export * from "./upload";
+1
View File
@@ -0,0 +1 @@
export const DECRYPTED_FILE_URL_PREFIX = "/_internal/decryptedFile/";
+6
View File
@@ -0,0 +1,6 @@
export const AES_GCM_IV_SIZE = 12;
export const AES_GCM_TAG_SIZE = 16;
export const ENCRYPTION_OVERHEAD = AES_GCM_IV_SIZE + AES_GCM_TAG_SIZE;
export const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB
export const ENCRYPTED_CHUNK_SIZE = CHUNK_SIZE + ENCRYPTION_OVERHEAD;
@@ -1 +0,0 @@
export { default as ForceLoginModal } from "./ForceLoginModal.svelte";
-13
View File
@@ -1,13 +0,0 @@
import { trpc } from "#trpc/client.ts";
export const login = async (email: string, password: string) => {
await trpc.auth.login.mutate({ email, password });
};
export const logout = async () => {
await trpc.auth.logout.mutate();
};
export const changePassword = async (oldPassword: string, newPassword: string) => {
await trpc.auth.changePassword.mutate({ oldPassword, newPassword });
};
-30
View File
@@ -1,30 +0,0 @@
import { showToast } from "#lib/ui/index.ts";
import { isTRPCClientError } from "#trpc/client.ts";
const getErrorMessage = (e: unknown, fallbackMessage: string) => {
if (isTRPCClientError(e)) {
switch (e.data?.code) {
case "NOT_FOUND":
return "대상을 찾을 수 없어요. 이미 삭제되었을 수 있어요.";
case undefined:
return "서버에 연결할 수 없어요.";
}
}
return fallbackMessage;
};
export const showErrorToast = (e: unknown, fallbackMessage: string) => {
console.error(e);
showToast(getErrorMessage(e, fallbackMessage));
};
// Runs the action and shows the error to the user if it fails. Returns whether it succeeded.
export const tryAction = async (action: () => Promise<unknown>, errorMessage: string) => {
try {
await action();
return true;
} catch (e) {
showErrorToast(e, errorMessage);
return false;
}
};
-54
View File
@@ -1,54 +0,0 @@
import {
getDirectoryInfo,
getFileInfo,
isExistingFileInfo,
isLocalDirectoryInfo,
setDirectoryFavorite,
setFileFavorite,
type LocalDirectoryInfo,
type SummarizedFileInfo,
} from "#lib/features/filesystem/index.ts";
import { sortEntries } from "#lib/utils/index.ts";
import { trpc } from "#trpc/client.ts";
export type FavoriteEntry =
| { type: "directory"; name: string; details: LocalDirectoryInfo }
| { type: "file"; name: string; details: SummarizedFileInfo };
export const getFavoriteEntries = async () => {
const favorites = await trpc.favorites.get.query();
const [directories, files] = await Promise.all([
Promise.all(
favorites.directories.map((directory) =>
getDirectoryInfo(directory.id, { serverResponse: { ...directory, isFavorite: true } }),
),
),
Promise.all(
favorites.files.map((file) =>
getFileInfo(file.id, { serverResponse: { ...file, isFavorite: true } }),
),
),
]);
return [
...sortEntries(
directories.filter(isLocalDirectoryInfo).map((directory): FavoriteEntry => ({
type: "directory",
name: directory.name,
details: directory,
})),
),
...sortEntries(
files
.filter(isExistingFileInfo)
.map((file): FavoriteEntry => ({ type: "file", name: file.name, details: file })),
),
];
};
export const removeFavorite = async ({ type, details }: FavoriteEntry) => {
if (type === "directory") {
await setDirectoryFavorite(details, false);
} else {
await setFileFavorite(details, false);
}
};
-150
View File
@@ -1,150 +0,0 @@
/* eslint-disable svelte/prefer-svelte-reactivity -- Lookup tables of reactive entries */
import { untrack } from "svelte";
interface FilesystemCacheOptions<K, V> {
fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
fetchFromServer: (key: K, cachedValue: V | undefined) => Promise<V>;
bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
bulkFetchFromServer?: (keys: Map<K, { cachedValue: V | undefined }>) => Promise<Map<K, V>>;
}
interface Entry<V> {
value?: V;
promise?: Promise<V>;
isInvalidated: boolean;
}
// Caches filesystem entries as reactive objects which are updated in place, so that every view
// of an entry reflects its latest state. Values are first loaded from IndexedDB (if any) and then
// revalidated against the server.
export class FilesystemCache<K, V extends object> {
private map = new Map<K, Entry<V>>();
constructor(private readonly options: FilesystemCacheOptions<K, V>) {}
private getOrCreateEntry(key: K) {
let entry = this.map.get(key);
if (!entry) {
const newEntry: Entry<V> = $state({ isInvalidated: false });
entry = newEntry;
this.map.set(key, entry);
}
return entry;
}
private settle(entry: Entry<V>, value: V) {
if (entry.value) {
Object.assign(entry.value, value);
} else {
entry.value = value;
}
return entry.value!;
}
private finish(key: K, entry: Entry<V>) {
entry.promise = undefined;
// Mutations may invalidate the entry while it is being revalidated, in which case it is queried
// again unless it was evicted or replaced in the meantime
if (entry.isInvalidated && this.map.get(key) === entry) {
void Promise.resolve(this.get(key)).catch(() => {});
}
}
// Returns the cached value synchronously if available, and revalidates it in the background
get(key: K, options?: { fetchFromServer?: (cachedValue: V | undefined) => Promise<V> }) {
return untrack(() => {
const entry = this.getOrCreateEntry(key);
if (entry.promise) return entry.value ?? entry.promise;
const { promise, resolve, reject } = Promise.withResolvers<V>();
promise.catch(() => {}); // Rejections are handled by callers waiting for the promise
entry.promise = promise;
entry.isInvalidated = false;
(async () => {
let cachedValue = entry.value && ($state.snapshot(entry.value) as V);
if (!cachedValue) {
cachedValue = await this.options.fetchFromIndexedDB(key);
if (cachedValue) {
resolve(this.settle(entry, cachedValue));
}
}
const loadedValue = await (options?.fetchFromServer?.(cachedValue) ??
this.options.fetchFromServer(key, cachedValue));
resolve(this.settle(entry, loadedValue));
})()
.catch((e) => {
console.error(e);
reject(e);
})
.finally(() => this.finish(key, entry));
return entry.value ?? promise;
});
}
bulkGet(keys: Set<K>) {
return untrack(() => {
const entries = new Map<K, Entry<V>>();
const pending = new Map<K, PromiseWithResolvers<V>>();
for (const key of keys) {
const entry = this.getOrCreateEntry(key);
entries.set(key, entry);
if (entry.promise) continue;
const resolvers = Promise.withResolvers<V>();
resolvers.promise.catch(() => {});
entry.promise = resolvers.promise;
entry.isInvalidated = false;
pending.set(key, resolvers);
}
const settleAll = (values: Map<K, V>) => {
values.forEach((value, key) => {
pending.get(key)?.resolve(this.settle(entries.get(key)!, value));
});
};
(async () => {
const pendingKeys = [...pending.keys()];
settleAll(
await this.options.bulkFetchFromIndexedDB!(
new Set(pendingKeys.filter((key) => !entries.get(key)!.value)),
),
);
settleAll(
await this.options.bulkFetchFromServer!(
new Map(pendingKeys.map((key) => [key, { cachedValue: entries.get(key)!.value }])),
),
);
})()
.catch((e) => {
console.error(e);
pending.forEach(({ reject }) => reject(e));
})
.finally(() => {
pending.forEach((_, key) => this.finish(key, entries.get(key)!));
});
const makeResult = () => new Map([...entries].map(([key, entry]) => [key, entry.value!]));
const waiting = [...entries.values()]
.filter((entry) => !entry.value)
.map((entry) => entry.promise!);
return waiting.length > 0 ? Promise.all(waiting).then(makeResult) : makeResult();
});
}
// Revalidates the entry if it has been loaded
invalidate(key: K) {
const entry = this.map.get(key);
if (entry) {
entry.isInvalidated = true;
void Promise.resolve(this.get(key)).catch(() => {});
}
}
evict(key: K) {
this.map.delete(key);
}
}
-168
View File
@@ -1,168 +0,0 @@
import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
import { isTRPCClientError, trpc } from "#trpc/client.ts";
import { FilesystemCache } from "./cache.svelte.ts";
import { invalidateFile } from "./file.ts";
import { toCategoryInfo, toFileInfo } from "./dto.ts";
import { encryptName, generateEntryKeys } from "./metadata.ts";
import { useResource } from "./resource.svelte.ts";
import type { CategoryInfo, DataKey, LocalCategoryInfo, MaybeCategoryInfo } from "./types.ts";
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
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,
isFavorite: fileInfo.isFavorite,
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) {
try {
const category = await trpc.category.get.query({ id, recurse: true });
const [subCategories, files, metadata] = await Promise.all([
Promise.all(category.subCategories.map(toCategoryInfo)),
Promise.all(
(category.files ?? []).map(async (file) => ({
...(await toFileInfo(file)),
isRecursive: file.isRecursive,
})),
),
category.metadata && toCategoryInfo(category.metadata),
]);
return storeToIndexedDB(
metadata
? {
...metadata,
subCategories,
files,
isFileRecursive: cachedInfo?.isFileRecursive ?? false,
}
: { id: "root", 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) => {
return cache.get(id);
};
export const useCategory = (getId: () => CategoryId) => {
return useResource(getId, (id) => getCategoryInfo(id));
};
export const invalidateCategory = (id: CategoryId) => {
cache.invalidate(id);
};
export const createCategory = async (parentId: CategoryId, name: string) => {
await trpc.category.create.mutate({ parent: parentId, ...(await generateEntryKeys(name)) });
invalidateCategory(parentId);
};
export const renameCategory = async (
category: { id: number; parentId: CategoryId; dataKey?: DataKey },
newName: string,
) => {
if (!category.dataKey) {
throw new Error("Data key not loaded");
}
await trpc.category.rename.mutate({
id: category.id,
dekVersion: category.dataKey.version,
...(await encryptName(newName, category.dataKey.key)),
});
invalidateCategory(category.parentId);
invalidateCategory(category.id);
};
export const deleteCategory = async (category: { id: number; parentId: CategoryId }) => {
await trpc.category.delete.mutate({ id: category.id });
cache.evict(category.id);
invalidateCategory(category.parentId);
};
export const addFileToCategory = async (fileId: number, categoryId: number) => {
await trpc.category.addFile.mutate({ id: categoryId, file: fileId });
invalidateFile(fileId);
invalidateCategory(categoryId);
};
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {
await trpc.category.removeFile.mutate({ id: categoryId, file: fileId });
invalidateFile(fileId);
invalidateCategory(categoryId);
};
// Whether to show the files of subcategories is a local preference
export const setCategoryFileRecursive = async (category: LocalCategoryInfo, value: boolean) => {
category.isFileRecursive = value;
await IndexedDB.updateCategoryInfo(category.id, { isFileRecursive: value });
};
@@ -1,25 +0,0 @@
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { ActionEntryButton, DirectoryEntryLabel } from "#lib/ui/index.ts";
interface Props {
action?: {
icon: Component<SvelteHTMLElements["svg"]>;
onclick: () => void;
};
info: { name: string; isFavorite?: boolean };
onclick?: () => void;
}
let { action, info, onclick }: Props = $props();
</script>
<ActionEntryButton
class="h-14"
{onclick}
actionButtonIcon={action?.icon}
onActionButtonClick={action?.onclick}
>
<DirectoryEntryLabel type="directory" name={info.name} isFavorite={info.isFavorite} />
</ActionEntryButton>
@@ -1,40 +0,0 @@
<script lang="ts">
import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { getFileThumbnail } from "#lib/client/cache/index.ts";
import { ActionEntryButton, DirectoryEntryLabel } from "#lib/ui/index.ts";
import { formatDateTime } from "#lib/utils/index.ts";
import type { SummarizedFileInfo } from "../types.ts";
interface Props {
action?: {
icon: Component<SvelteHTMLElements["svg"]>;
onclick: () => void;
class?: ClassValue;
};
info: SummarizedFileInfo;
onclick?: () => void;
showThumbnail?: boolean;
subtext?: string;
}
let { action, info, onclick, showThumbnail = true, subtext }: Props = $props();
let thumbnail = $derived(showThumbnail ? getFileThumbnail(info) : undefined);
</script>
<ActionEntryButton
class="h-14"
{onclick}
actionButtonIcon={action?.icon}
actionButtonClass={action?.class}
onActionButtonClick={action?.onclick}
>
<DirectoryEntryLabel
type="file"
thumbnail={thumbnail?.url}
name={info.name}
subtext={subtext ?? formatDateTime(info.createdAt ?? info.lastModifiedAt)}
isFavorite={info.isFavorite}
/>
</ActionEntryButton>
@@ -1,35 +0,0 @@
<script lang="ts">
import { getFileThumbnail } from "#lib/client/cache/index.ts";
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.ts";
import { afterPress } from "#lib/ui/index.ts";
import IconFavorite from "~icons/material-symbols/favorite";
interface Props {
info: SummarizedFileInfo;
onclick?: (file: SummarizedFileInfo) => void;
}
let { info, onclick }: Props = $props();
let thumbnail = $derived(getFileThumbnail(info));
</script>
<button
onclick={afterPress(onclick && (() => onclick(info)))}
class="relative aspect-square overflow-hidden rounded-sm transition active:scale-95 active:brightness-90"
>
{#if thumbnail?.url}
<img src={thumbnail?.url} alt={info.name} class="size-full object-cover" />
{:else}
<div class="size-full bg-gray-100"></div>
{/if}
{#if info.isFavorite}
<div class={["absolute right-0.5 bottom-0.5"]}>
<IconFavorite
class="text-sm text-red-500"
style="filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px white)"
/>
</div>
{/if}
</button>
@@ -1,7 +0,0 @@
export * from "./Categories.svelte";
export { default as Categories } from "./Categories.svelte";
export { default as CategoryCreateModal } from "./CategoryCreateModal.svelte";
export { default as DirectoryEntry } from "./DirectoryEntry.svelte";
export { default as FileEntry } from "./FileEntry.svelte";
export { default as FileThumbnailButton } from "./FileThumbnailButton.svelte";
export { default as SubCategories } from "./SubCategories.svelte";
-67
View File
@@ -1,67 +0,0 @@
import {
deleteFileCache,
deleteFileThumbnailCache,
storeFileThumbnailCache,
} from "#lib/client/cache/index.ts";
import {
getDecryptedFileUrl,
prepareFileDecryption,
saveDecryptedFile,
} from "#lib/client/service-worker.ts";
import { getAllFileInfos } from "#lib/client/storage/indexed-db/index.ts";
import { uploadBlob } from "#lib/client/transfer/chunked-upload.ts";
import { trpc } from "#trpc/client.ts";
import type { DataKey } from "./types.ts";
interface StreamableFile {
id: number;
isLegacy?: boolean;
dataKey?: DataKey;
contentType: string;
}
// Returns a URL from which the service worker streams the decrypted content of the file
export const getFileStreamUrl = async ({ id, isLegacy, dataKey, contentType }: StreamableFile) => {
if (!dataKey) {
throw new Error("Data key not loaded");
}
await prepareFileDecryption(id, { isLegacy: !!isLegacy, dataKey: dataKey.key, contentType });
return getDecryptedFileUrl(id);
};
export const getFileBlob = async (file: StreamableFile) => {
const res = await fetch(await getFileStreamUrl(file));
if (!res.ok) {
throw new Error("Failed to download the file");
}
return await res.blob();
};
export const saveFile = async (file: StreamableFile & { name: string }) => {
await getFileStreamUrl(file);
saveDecryptedFile(file.id, file.name);
};
export const uploadFileThumbnail = async (fileId: number, thumbnail: Blob, dataKey: DataKey) => {
const { uploadId } = await trpc.upload.startFileThumbnailUpload.mutate({
file: fileId,
dekVersion: dataKey.version,
});
await uploadBlob(uploadId, thumbnail, dataKey.key);
await trpc.upload.completeFileThumbnailUpload.mutate({ uploadId });
await storeFileThumbnailCache(fileId, await thumbnail.arrayBuffer());
};
// Deletes local caches of files deleted on other devices
export const cleanupDeletedFileCaches = async () => {
const liveFiles = new Set(await trpc.file.list.query());
const maybeCachedFiles = await getAllFileInfos();
await Promise.all(
maybeCachedFiles
.filter(({ id }) => !liveFiles.has(id))
.flatMap(({ id }) => [deleteFileCache(id), deleteFileThumbnailCache(id)]),
);
};
-151
View File
@@ -1,151 +0,0 @@
import { deleteFileCache, deleteFileThumbnailCache } from "#lib/client/cache/index.ts";
import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
import { isTRPCClientError, trpc } from "#trpc/client.ts";
import { FilesystemCache } from "./cache.svelte.ts";
import { toDirectoryInfo, toFileInfo, type DirectoryDto } from "./dto.ts";
import { encryptName, generateEntryKeys } from "./metadata.ts";
import { useResource } from "./resource.svelte.ts";
import type { DataKey, DirectoryInfo, MaybeDirectoryInfo } from "./types.ts";
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
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,
isFavorite: directory.isFavorite,
};
}
},
async fetchFromServer(id) {
try {
const directory = await trpc.directory.get.query({ id });
const [subDirectories, files, metadata] = await Promise.all([
Promise.all(directory.subDirectories.map(toDirectoryInfo)),
Promise.all(directory.files.map(toFileInfo)),
directory.metadata && toDirectoryInfo(directory.metadata),
]);
return storeToIndexedDB(
metadata ? { ...metadata, subDirectories, files } : { id: "root", 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 };
};
// Uses `serverResponse` instead of fetching the directory from the server if given
export const getDirectoryInfo = (id: DirectoryId, options?: { serverResponse?: DirectoryDto }) => {
const serverResponse = options?.serverResponse;
return cache.get(id, {
fetchFromServer:
serverResponse &&
(async (cachedValue) =>
storeToIndexedDB({
subDirectories: [],
files: [],
...cachedValue,
...(await toDirectoryInfo(serverResponse)),
})),
});
};
export const useDirectory = (getId: () => DirectoryId) => {
return useResource(getId, (id) => getDirectoryInfo(id));
};
export const invalidateDirectory = (id: DirectoryId) => {
cache.invalidate(id);
};
export const createDirectory = async (parentId: DirectoryId, name: string) => {
await trpc.directory.create.mutate({ parent: parentId, ...(await generateEntryKeys(name)) });
invalidateDirectory(parentId);
};
export const renameDirectory = async (
directory: { id: number; parentId: DirectoryId; dataKey?: DataKey },
newName: string,
) => {
if (!directory.dataKey) {
throw new Error("Data key not loaded");
}
await trpc.directory.rename.mutate({
id: directory.id,
dekVersion: directory.dataKey.version,
...(await encryptName(newName, directory.dataKey.key)),
});
invalidateDirectory(directory.parentId);
invalidateDirectory(directory.id);
};
export const deleteDirectory = async (directory: { id: number; parentId: DirectoryId }) => {
const { deletedFiles } = await trpc.directory.delete.mutate({ id: directory.id });
cache.evict(directory.id);
invalidateDirectory(directory.parentId);
await Promise.all(
deletedFiles.flatMap((fileId) => [deleteFileCache(fileId), deleteFileThumbnailCache(fileId)]),
);
};
export const setDirectoryFavorite = async (
directory: { id: number; parentId: DirectoryId },
isFavorite: boolean,
) => {
if (isFavorite) {
await trpc.favorites.addDirectory.mutate({ id: directory.id });
} else {
await trpc.favorites.removeDirectory.mutate({ id: directory.id });
}
invalidateDirectory(directory.parentId);
invalidateDirectory(directory.id);
};
-46
View File
@@ -1,46 +0,0 @@
import type { RouterOutputs } from "#trpc/router.server.ts";
import {
decryptCategoryMetadata,
decryptDirectoryMetadata,
decryptFileMetadata,
} from "./metadata.ts";
import type { FileInfo, SubCategoryInfo, SubDirectoryInfo, SummarizedFileInfo } from "./types.ts";
// Shapes of filesystem entries returned by the server
export type DirectoryDto = RouterOutputs["directory"]["get"]["subDirectories"][number];
export type FileDto = RouterOutputs["directory"]["get"]["files"][number];
export type CategoryDto = RouterOutputs["category"]["get"]["subCategories"][number];
export type FileWithCategoriesDto = RouterOutputs["file"]["get"];
export const toDirectoryInfo = async (directory: DirectoryDto): Promise<SubDirectoryInfo> => ({
id: directory.id,
parentId: directory.parent,
isFavorite: directory.isFavorite,
...(await decryptDirectoryMetadata(directory)),
});
export const toFileInfo = async (file: FileDto): Promise<SummarizedFileInfo> => ({
id: file.id,
isLegacy: file.isLegacy,
parentId: file.parent,
contentType: file.contentType,
isFavorite: file.isFavorite,
...(await decryptFileMetadata(file)),
});
export const toCategoryInfo = async (category: CategoryDto): Promise<SubCategoryInfo> => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category)),
});
export const toFileWithCategoriesInfo = async ({
categories,
...file
}: FileWithCategoriesDto): Promise<FileInfo> => {
const [fileInfo, categoryInfos] = await Promise.all([
toFileInfo(file),
Promise.all(categories.map(toCategoryInfo)),
]);
return { ...fileInfo, categories: categoryInfos };
};
-196
View File
@@ -1,196 +0,0 @@
import { deleteFileCache, deleteFileThumbnailCache } from "#lib/client/cache/index.ts";
import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
import { isTRPCClientError, trpc } from "#trpc/client.ts";
import { FilesystemCache } from "./cache.svelte.ts";
import { invalidateDirectory } from "./directory.ts";
import { toFileInfo, toFileWithCategoriesInfo, type FileDto } from "./dto.ts";
import { encryptName } from "./metadata.ts";
import { useResource } from "./resource.svelte.ts";
import type { DataKey, FileInfo, MaybeFileInfo } from "./types.ts";
// Categories of files stored in IndexedDB, which may have been deleted
const getFileCategoryInfos = async (categoryIds: number[] | undefined) => {
const categories = await Promise.all(
(categoryIds ?? []).map((id) => IndexedDB.getCategoryInfo(id)),
);
return categories
.filter((category) => !!category)
.map(({ id, parentId, name }) => ({ id, parentId, name }));
};
const cache = new FilesystemCache<number, MaybeFileInfo>({
async fetchFromIndexedDB(id) {
const file = await IndexedDB.getFileInfo(id);
if (file) {
return {
id,
exists: true,
parentId: file.parentId,
contentType: file.contentType,
name: file.name,
createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt,
categories: await getFileCategoryInfos(file.categoryIds),
isFavorite: file.isFavorite,
};
}
},
async fetchFromServer(id) {
try {
return storeToIndexedDB(await toFileWithCategoriesInfo(await trpc.file.get.query({ id })));
} 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])).filter((file) => !!file);
return new Map(
await Promise.all(
files.map(
async (file) =>
[
file.id,
{
...file,
exists: true as const,
categories: await getFileCategoryInfos(file.categoryIds),
},
] as const,
),
),
);
},
async bulkFetchFromServer(ids) {
const idsArray = [...ids.keys()];
const filesRaw = await trpc.file.bulkGet.query({ ids: idsArray });
const files = await Promise.all(filesRaw.map(toFileWithCategoriesInfo));
const existingIds = new Set(filesRaw.map(({ id }) => id));
const deletedIds = idsArray.filter((id) => !existingIds.has(id));
void IndexedDB.bulkDeleteFileInfos(deletedIds);
return new Map<number, MaybeFileInfo>([
...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);
};
// Uses `serverResponse` instead of fetching the file from the server if given
export const getFileInfo = (id: number, options?: { serverResponse?: FileDto }) => {
const serverResponse = options?.serverResponse;
return cache.get(id, {
fetchFromServer:
serverResponse &&
(async (cachedValue) =>
storeToIndexedDB({
categories: [],
...cachedValue,
...(await toFileInfo(serverResponse)),
})),
});
};
export const bulkGetFileInfo = (ids: number[]) => {
return cache.bulkGet(new Set(ids));
};
export const useFile = (getId: () => number) => {
return useResource(getId, (id) => getFileInfo(id));
};
export const invalidateFile = (id: number) => {
cache.invalidate(id);
};
export const renameFile = async (
file: { id: number; parentId: DirectoryId; dataKey?: DataKey },
newName: string,
) => {
if (!file.dataKey) {
throw new Error("Data key not loaded");
}
await trpc.file.rename.mutate({
id: file.id,
dekVersion: file.dataKey.version,
...(await encryptName(newName, file.dataKey.key)),
});
invalidateDirectory(file.parentId);
invalidateFile(file.id);
};
export const deleteFile = async (file: { id: number; parentId: DirectoryId }) => {
await trpc.file.delete.mutate({ id: file.id });
cache.evict(file.id);
invalidateDirectory(file.parentId);
await Promise.all([deleteFileCache(file.id), deleteFileThumbnailCache(file.id)]);
};
export const setFileFavorite = async (
file: { id: number; parentId: DirectoryId },
isFavorite: boolean,
) => {
if (isFavorite) {
await trpc.favorites.addFile.mutate({ id: file.id });
} else {
await trpc.favorites.removeFile.mutate({ id: file.id });
}
invalidateDirectory(file.parentId);
invalidateFile(file.id);
};
// The most recently created media files among the files stored locally
export const getRecentMediaFileInfos = async (limit: number) => {
const files = await IndexedDB.getAllFileInfos();
return files
.filter(
({ contentType }) => contentType.startsWith("image/") || contentType.startsWith("video/"),
)
.sort(
(a, b) =>
(b.createdAt ?? b.lastModifiedAt).getTime() - (a.createdAt ?? a.lastModifiedAt).getTime(),
)
.slice(0, limit);
};
-6
View File
@@ -1,6 +0,0 @@
export * from "./category.ts";
export * from "./content.ts";
export * from "./directory.ts";
export * from "./file.ts";
export * from "./types.ts";
export * from "./upload.ts";
-83
View File
@@ -1,83 +0,0 @@
import { keyring } from "#lib/client/keys/index.ts";
import {
decryptString,
encryptString,
generateDataKey,
unwrapDataKey,
wrapDataKey,
} from "#lib/shared/crypto/index.ts";
const unwrapEntryDataKey = async (metadata: { mekVersion: number; dek: string }) => {
const { dataKey } = await unwrapDataKey(
metadata.dek,
keyring.getMasterKey(metadata.mekVersion).key,
);
return dataKey;
};
export const decryptDirectoryMetadata = async (metadata: {
mekVersion: number;
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
}) => {
const dataKey = await unwrapEntryDataKey(metadata);
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: {
mekVersion: number;
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
createdAt?: string;
createdAtIv?: string;
lastModifiedAt: string;
lastModifiedAtIv: string;
}) => {
const dataKey = await unwrapEntryDataKey(metadata);
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;
export const encryptName = async (name: string, dataKey: CryptoKey) => {
const { ciphertext, iv } = await encryptString(name, dataKey);
return { name: ciphertext, nameIv: iv };
};
// Generates the keys of a new directory or category encrypted with the active master key
export const generateEntryKeys = async (name: string) => {
const masterKey = keyring.activeMasterKey;
const { dataKey, dataKeyVersion } = await generateDataKey();
return {
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion,
...(await encryptName(name, dataKey)),
};
};
@@ -1,33 +0,0 @@
import { untrack } from "svelte";
// Exposes the value of a cache entry identified by a reactive key. Cached values are available
// synchronously, so that views don't flicker while revalidating.
export const useResource = <K, V>(getKey: () => K, load: (key: K) => V | Promise<V>) => {
let current: V | undefined = $state();
$effect.pre(() => {
const key = getKey();
let isStale = false;
const result = untrack(() => load(key));
if (result instanceof Promise) {
result
.then((value) => {
if (!isStale) current = value;
})
.catch(() => {}); // Logged by the cache
} else {
current = result;
}
return () => {
isStale = true;
};
});
return {
get current() {
return current;
},
};
};
-19
View File
@@ -1,19 +0,0 @@
import { storeFileThumbnailCache } from "#lib/client/cache/index.ts";
import { uploadFile as doUploadFile } from "#lib/client/transfer/index.ts";
import { invalidateDirectory } from "./directory.ts";
// Uploads the file and caches it with its thumbnail locally. Returns `false` if the upload is canceled.
export const uploadFile = async (
file: File,
parentId: DirectoryId,
onDuplicate: () => Promise<boolean>,
) => {
const res = await doUploadFile(file, parentId, onDuplicate);
if (!res) return false;
invalidateDirectory(parentId);
if (res.thumbnailBuffer) {
void storeFileThumbnailCache(res.fileId, res.thumbnailBuffer);
}
return true;
};
-46
View File
@@ -1,46 +0,0 @@
import {
getDirectoryInfo,
getFileInfo,
isExistingFileInfo,
isLocalDirectoryInfo,
type FileInfo,
type LocalCategoryInfo,
type LocalDirectoryInfo,
} from "#lib/features/filesystem/index.ts";
import { trpc } from "#trpc/client.ts";
export interface SearchFilter {
ancestorId: DirectoryId;
inFavorites: boolean;
categories: { info: LocalCategoryInfo; type: "include" | "exclude" }[];
}
export interface SearchResult {
directories: LocalDirectoryInfo[];
files: FileInfo[];
}
const getCategoryIds = (filter: SearchFilter, type: "include" | "exclude") => {
return filter.categories.filter((category) => category.type === type).map(({ info }) => info.id);
};
export const search = async (filter: SearchFilter) => {
const { directories: directoriesRaw, files: filesRaw } = await trpc.search.search.query({
ancestor: filter.ancestorId,
inFavorites: filter.inFavorites,
includeCategories: getCategoryIds(filter, "include"),
excludeCategories: getCategoryIds(filter, "exclude"),
});
const [directories, files] = await Promise.all([
Promise.all(
directoriesRaw.map((directory) =>
getDirectoryInfo(directory.id, { serverResponse: directory }),
),
),
Promise.all(filesRaw.map((file) => getFileInfo(file.id, { serverResponse: file }))),
]);
return {
directories: directories.filter(isLocalDirectoryInfo),
files: files.filter(isExistingFileInfo),
} satisfies SearchResult;
};
@@ -1,12 +0,0 @@
<script lang="ts">
import { formatNetworkSpeed } from "#lib/utils/index.ts";
interface Props {
progress?: number;
rate?: number; // Bytes per second
}
let { progress, rate }: Props = $props();
</script>
전송됨 {Math.floor((progress ?? 0) * 100)}% · {formatNetworkSpeed((rate ?? 0) * 8)}
@@ -1,26 +0,0 @@
<script lang="ts">
import type { FileUploadState } from "#lib/client/transfer/index.ts";
import TransferProgress from "./TransferProgress.svelte";
interface Props {
state: FileUploadState;
}
let { state }: Props = $props();
const statusTexts = {
queued: "대기 중",
"encryption-pending": "준비 중",
encrypting: "암호화하는 중",
"upload-pending": "업로드를 기다리는 중",
uploaded: "업로드 완료",
canceled: "",
error: "업로드 실패",
} satisfies Record<Exclude<FileUploadState["status"], "uploading">, string>;
</script>
{#if state.status === "uploading"}
<TransferProgress progress={state.progress} rate={state.rate} />
{:else}
{statusTexts[state.status]}
{/if}
@@ -1,2 +0,0 @@
export { default as TransferProgress } from "./TransferProgress.svelte";
export { default as UploadStatusText } from "./UploadStatusText.svelte";
+33
View File
@@ -0,0 +1,33 @@
import { Dexie, type EntityTable } from "dexie";
export interface FileCacheIndex {
fileId: number;
cachedAt: Date;
lastRetrievedAt: Date;
size: number;
}
const cacheIndex = new Dexie("cacheIndex") as Dexie & {
fileCache: EntityTable<FileCacheIndex, "fileId">;
};
cacheIndex.version(1).stores({
fileCache: "fileId",
});
export const getFileCacheIndex = async () => {
return await cacheIndex.fileCache.toArray();
};
export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => {
await cacheIndex.fileCache.put({
fileId: fileCacheIndex.fileId,
cachedAt: fileCacheIndex.cachedAt,
lastRetrievedAt: fileCacheIndex.lastRetrievedAt,
size: fileCacheIndex.size,
});
};
export const deleteFileCacheIndex = async (fileId: number) => {
await cacheIndex.fileCache.delete(fileId);
};
+3
View File
@@ -0,0 +1,3 @@
export * from "./cacheIndex";
export * from "./filesystem";
export * from "./keyStore";
File renamed without changes.
@@ -1,13 +1,11 @@
import { AES_GCM_IV_SIZE } from "$lib/constants";
import { import {
encodeString, encodeString,
decodeString, decodeString,
encodeToBase64, encodeToBase64,
decodeFromBase64, decodeFromBase64,
concatenateBuffers, concatenateBuffers,
} from "./encoding.ts"; } from "./utils";
export const AES_GCM_IV_SIZE = 12;
export const AES_GCM_TAG_SIZE = 16;
export const generateMasterKey = async () => { export const generateMasterKey = async () => {
return { return {
@@ -46,12 +44,10 @@ export const makeAESKeyNonextractable = async (key: CryptoKey) => {
); );
}; };
const wrapKey = async (key: CryptoKey, masterKey: CryptoKey) => { export const wrapDataKey = async (dataKey: CryptoKey, masterKey: CryptoKey) => {
return encodeToBase64(await crypto.subtle.wrapKey("raw", key, masterKey, "AES-KW")); return encodeToBase64(await crypto.subtle.wrapKey("raw", dataKey, masterKey, "AES-KW"));
}; };
export const wrapDataKey = wrapKey;
export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => { export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => {
return { return {
dataKey: await crypto.subtle.unwrapKey( dataKey: await crypto.subtle.unwrapKey(
@@ -66,7 +62,9 @@ export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey
}; };
}; };
export const wrapHmacSecret = wrapKey; export const wrapHmacSecret = async (hmacSecret: CryptoKey, masterKey: CryptoKey) => {
return encodeToBase64(await crypto.subtle.wrapKey("raw", hmacSecret, masterKey, "AES-KW"));
};
export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => { export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => {
return { return {
@@ -86,7 +84,7 @@ export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: Cry
}; };
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => { export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_SIZE)); const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt( const ciphertext = await crypto.subtle.encrypt(
{ {
name: "AES-GCM", name: "AES-GCM",
+4
View File
@@ -0,0 +1,4 @@
export * from "./aes";
export * from "./rsa";
export * from "./sha";
export * from "./utils";
@@ -1,4 +1,4 @@
import { encodeString, encodeToBase64, decodeFromBase64 } from "./encoding.ts"; import { encodeString, encodeToBase64, decodeFromBase64 } from "./utils";
export const generateEncryptionKeyPair = async () => { export const generateEncryptionKeyPair = async () => {
const keyPair = await crypto.subtle.generateKey( const keyPair = await crypto.subtle.generateKey(
@@ -166,21 +166,15 @@ export const verifySignatureRSA = async (
); );
}; };
// The message signed by clients to prove the integrity of the wrapped master key. Also verified
// by the server, so it must not be changed.
export const serializeMasterKeyWrapped = (masterKeyWrapped: string, masterKeyVersion: number) => {
return JSON.stringify({
version: masterKeyVersion,
key: masterKeyWrapped,
});
};
export const signMasterKeyWrapped = async ( export const signMasterKeyWrapped = async (
masterKeyWrapped: string, masterKeyWrapped: string,
masterKeyVersion: number, masterKeyVersion: number,
signKey: CryptoKey, signKey: CryptoKey,
) => { ) => {
const serialized = serializeMasterKeyWrapped(masterKeyWrapped, masterKeyVersion); const serialized = JSON.stringify({
version: masterKeyVersion,
key: masterKeyWrapped,
});
return encodeToBase64(await signMessageRSA(encodeString(serialized), signKey)); return encodeToBase64(await signMessageRSA(encodeString(serialized), signKey));
}; };
@@ -190,7 +184,10 @@ export const verifyMasterKeyWrapped = async (
masterKeyWrappedSig: string, masterKeyWrappedSig: string,
verifyKey: CryptoKey, verifyKey: CryptoKey,
) => { ) => {
const serialized = serializeMasterKeyWrapped(masterKeyWrapped, masterKeyVersion); const serialized = JSON.stringify({
version: masterKeyVersion,
key: masterKeyWrapped,
});
return await verifySignatureRSA( return await verifySignatureRSA(
encodeString(serialized), encodeString(serialized),
decodeFromBase64(masterKeyWrappedSig), decodeFromBase64(masterKeyWrappedSig),
Loaded 100 of 385 files, more files were not shown because too many files have changed in this diff. Show more