Author SHA1 Message Date
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
408 changed files with 8625 additions and 18728 deletions

No files matched your search

+3 -6
View File
@@ -1,6 +1,5 @@
.git .git
node_modules node_modules
/Makefile
# Output # Output
.output .output
@@ -11,15 +10,13 @@ node_modules
/build /build
/data /data
/library /library
/thumbnails
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Editors # VSCode
/.vscode /.vscode
/.idea
# Env # Env
.env .env
@@ -31,5 +28,5 @@ Thumbs.db
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# E2E tests # SQLite
/test/e2e/results *.db
+1 -6
View File
@@ -1,14 +1,9 @@
# Required environment variables # Required environment variables
DATABASE_PASSWORD=
SESSION_SECRET= SESSION_SECRET=
# Optional environment variables # Optional environment variables
DATABASE_HOST= DATABASE_URL=
DATABASE_PORT=
DATABASE_USER=
DATABASE_NAME=
SESSION_EXPIRES= SESSION_EXPIRES=
USER_CLIENT_CHALLENGE_EXPIRES= USER_CLIENT_CHALLENGE_EXPIRES=
SESSION_UPGRADE_CHALLENGE_EXPIRES= SESSION_UPGRADE_CHALLENGE_EXPIRES=
LIBRARY_PATH= LIBRARY_PATH=
THUMBNAILS_PATH=
-45
View File
@@ -1,45 +0,0 @@
name: Docker Image Build
on:
release:
types: [published]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+3 -5
View File
@@ -9,15 +9,13 @@ node_modules
/build /build
/data /data
/library /library
/thumbnails
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Editors # VSCode
/.vscode /.vscode
/.idea
# Env # Env
.env .env
@@ -29,5 +27,5 @@ Thumbs.db
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# E2E tests # SQLite
/test/e2e/results *.db
+3
View File
@@ -3,5 +3,8 @@ package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
yarn.lock yarn.lock
# Output
/drizzle
# Documents # Documents
*.md *.md
-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",
+6 -5
View File
@@ -2,17 +2,16 @@
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@9
COPY pnpm-lock.yaml pnpm-workspace.yaml ./ COPY pnpm-lock.yaml .
# Build Stage # Build Stage
FROM base AS build FROM base AS build
RUN pnpm fetch RUN pnpm fetch
COPY . . COPY . .
RUN pnpm install --offline && \ RUN pnpm install --offline
pnpm build && \ RUN pnpm build
sed -i "s/http\.createServer()/http.createServer({ requestTimeout: 0 })/g" ./build/index.js
# Deploy Stage # Deploy Stage
FROM base FROM base
@@ -22,7 +21,9 @@ COPY package.json .
RUN pnpm install --offline --prod RUN pnpm install --offline --prod
COPY --from=build /app/build ./build COPY --from=build /app/build ./build
COPY drizzle ./drizzle
EXPOSE 3000 EXPOSE 3000
ENV BODY_SIZE_LIMIT=Infinity ENV BODY_SIZE_LIMIT=Infinity
CMD ["node", "./build/index.js"] CMD ["node", "./build/index.js"]
+2 -3
View File
@@ -23,7 +23,7 @@ vim .env # 아래를 참고하여 환경 변수를 설정해 주세요.
docker compose up --build -d docker compose up --build -d
``` ```
모든 데이터는 `./data` 디렉터리에 아래에 저장될 거예요. 모든 데이터는 `./data` 디렉터리에 저장될 거예요.
### Environment Variables ### Environment Variables
@@ -31,8 +31,7 @@ docker compose up --build -d
|이름|필수|기본값|설명| |이름|필수|기본값|설명|
|:-|:-:|:-:|:-| |:-|:-:|:-:|:-|
|`DATABASE_PASSWORD`|Y||데이터베이스에 접근하기 위해 필요한 비밀번호예요. 안전한 값으로 설정해 주세요.| |`SESSION_SECRET`|Y||Session ID의 서명을 위해 사용돼요. 안전한 값으로 설정해 주세요.|
|`SESSION_SECRET`|Y||Session ID의 서명에 사용되는 비밀번호예요. 안전한 값으로 설정해 주세요.|
|`SESSION_EXPIRES`||`14d`|Session의 유효 시간이에요. Session은 마지막으로 사용된 후 설정된 유효 시간이 지나면 자동으로 삭제돼요.| |`SESSION_EXPIRES`||`14d`|Session의 유효 시간이에요. Session은 마지막으로 사용된 후 설정된 유효 시간이 지나면 자동으로 삭제돼요.|
|`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.| |`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.|
|`SESSION_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.| |`SESSION_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.|
-15
View File
@@ -1,15 +0,0 @@
services:
database:
image: postgres:17
restart: always
volumes:
- database:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${DATABASE_USER:-}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?} # Required
- POSTGRES_DB=${DATABASE_NAME:-}
ports:
- ${DATABASE_PORT:-5432}:5432
volumes:
database:
+2 -24
View File
@@ -2,42 +2,20 @@ services:
server: server:
build: . build: .
restart: unless-stopped restart: unless-stopped
depends_on:
database:
condition: service_healthy
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0} user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
volumes: volumes:
- ./data/library:/app/data/library - ./data:/app/data
- ./data/thumbnails:/app/data/thumbnails
environment: environment:
# ArkVault # ArkVault
- DATABASE_HOST=database - DATABASE_URL=/app/data/database.sqlite
- DATABASE_USER=arkvault
- DATABASE_PASSWORD=${DATABASE_PASSWORD:?} # Required
- SESSION_SECRET=${SESSION_SECRET:?} # Required - SESSION_SECRET=${SESSION_SECRET:?} # Required
- SESSION_EXPIRES - SESSION_EXPIRES
- USER_CLIENT_CHALLENGE_EXPIRES - USER_CLIENT_CHALLENGE_EXPIRES
- SESSION_UPGRADE_CHALLENGE_EXPIRES - SESSION_UPGRADE_CHALLENGE_EXPIRES
- LIBRARY_PATH=/app/data/library - LIBRARY_PATH=/app/data/library
- THUMBNAILS_PATH=/app/data/thumbnails
# SvelteKit # SvelteKit
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For} - ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
- XFF_DEPTH=${TRUST_PROXY:-} - XFF_DEPTH=${TRUST_PROXY:-}
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-production}
ports: ports:
- ${PORT:-80}:3000 - ${PORT:-80}:3000
database:
image: postgres:17-alpine
restart: unless-stopped
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
volumes:
- ./data/database:/var/lib/postgresql/data
environment:
- POSTGRES_USER=arkvault
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/lib/server/db/schema",
dbCredentials: {
url: process.env.DATABASE_URL || "local.db",
},
verbose: true,
strict: true,
dialect: "sqlite",
});
+175
View File
@@ -0,0 +1,175 @@
CREATE TABLE `client` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`encryption_public_key` text NOT NULL,
`signature_public_key` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `user_client` (
`user_id` integer NOT NULL,
`client_id` integer NOT NULL,
`state` text DEFAULT 'challenging' NOT NULL,
PRIMARY KEY(`client_id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `user_client_challenge` (
`id` integer PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`client_id` integer NOT NULL,
`answer` text NOT NULL,
`allowed_ip` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`client_id`) REFERENCES `user_client`(`user_id`,`client_id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `directory` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`parent_id` integer,
`user_id` integer NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`encrypted_data_encryption_key` text NOT NULL,
`data_encryption_key_version` integer NOT NULL,
`encrypted_name` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `directory_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`directory_id` integer NOT NULL,
`timestamp` integer NOT NULL,
`action` text NOT NULL,
`new_name` text,
FOREIGN KEY (`directory_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `file` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`parent_id` integer,
`user_id` integer NOT NULL,
`path` text NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`encrypted_data_encryption_key` text NOT NULL,
`data_encryption_key_version` integer NOT NULL,
`hmac_secret_key_version` integer,
`content_hmac` text,
`content_type` text NOT NULL,
`encrypted_content_iv` text NOT NULL,
`encrypted_name` text NOT NULL,
FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`hmac_secret_key_version`) REFERENCES `hmac_secret_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `file_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`file_id` integer NOT NULL,
`timestamp` integer NOT NULL,
`action` text NOT NULL,
`new_name` text,
FOREIGN KEY (`file_id`) REFERENCES `file`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `hmac_secret_key` (
`user_id` integer NOT NULL,
`version` integer NOT NULL,
`state` text NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`encrypted_key` text NOT NULL,
PRIMARY KEY(`user_id`, `version`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `hmac_secret_key_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`hmac_secret_key_version` integer NOT NULL,
`timestamp` integer NOT NULL,
`action` text NOT NULL,
`action_by` integer,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`action_by`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`hmac_secret_key_version`) REFERENCES `hmac_secret_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `client_master_encryption_key` (
`user_id` integer NOT NULL,
`client_id` integer NOT NULL,
`version` integer NOT NULL,
`encrypted_key` text NOT NULL,
`encrypted_key_signature` text NOT NULL,
PRIMARY KEY(`client_id`, `user_id`, `version`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `master_encryption_key` (
`user_id` integer NOT NULL,
`version` integer NOT NULL,
`state` text NOT NULL,
`retired_at` integer,
PRIMARY KEY(`user_id`, `version`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `master_encryption_key_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`timestamp` integer NOT NULL,
`action` text NOT NULL,
`action_by` integer,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`action_by`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`client_id` integer,
`created_at` integer NOT NULL,
`last_used_at` integer NOT NULL,
`last_used_by_ip` text,
`last_used_by_user_agent` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `session_upgrade_challenge` (
`id` integer PRIMARY KEY NOT NULL,
`session_id` text NOT NULL,
`client_id` integer NOT NULL,
`answer` text NOT NULL,
`allowed_ip` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`email` text NOT NULL,
`password` text NOT NULL,
`nickname` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `client_encryption_public_key_unique` ON `client` (`encryption_public_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `client_signature_public_key_unique` ON `client` (`signature_public_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `client_encryption_public_key_signature_public_key_unique` ON `client` (`encryption_public_key`,`signature_public_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_client_challenge_answer_unique` ON `user_client_challenge` (`answer`);--> statement-breakpoint
CREATE UNIQUE INDEX `directory_encrypted_data_encryption_key_unique` ON `directory` (`encrypted_data_encryption_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `file_path_unique` ON `file` (`path`);--> statement-breakpoint
CREATE UNIQUE INDEX `file_encrypted_data_encryption_key_unique` ON `file` (`encrypted_data_encryption_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `hmac_secret_key_encrypted_key_unique` ON `hmac_secret_key` (`encrypted_key`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_user_id_client_id_unique` ON `session` (`user_id`,`client_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_upgrade_challenge_session_id_unique` ON `session_upgrade_challenge` (`session_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_upgrade_challenge_answer_unique` ON `session_upgrade_challenge` (`answer`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);
File diff suppressed because it is too large. Load diff
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1736704436996,
"tag": "0000_unknown_stark_industries",
"breakpoints": true
}
]
}
+9 -50
View File
@@ -1,40 +1,21 @@
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import { loadConfig } from "@sveltejs/load-config";
import { defineConfig } from "eslint/config";
import prettier from "eslint-config-prettier"; import prettier from "eslint-config-prettier";
import js from "@eslint/js";
import { includeIgnoreFile } from "@eslint/compat";
import svelte from "eslint-plugin-svelte"; import svelte from "eslint-plugin-svelte";
import tailwind from "eslint-plugin-tailwindcss"; import tailwind from "eslint-plugin-tailwindcss";
import globals from "globals"; import globals from "globals";
import { fileURLToPath } from "node:url";
import ts from "typescript-eslint"; import ts from "typescript-eslint";
import { fileURLToPath } from "url";
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 export default ts.config(
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(
includeIgnoreFile(gitignorePath), includeIgnoreFile(gitignorePath),
js.configs.recommended, js.configs.recommended,
...ts.configs.recommended, ...ts.configs.recommended,
...svelte.configs.recommended, ...svelte.configs["flat/recommended"],
tailwind.configs.recommended, ...tailwind.configs["flat/recommended"],
prettier, prettier,
...svelte.configs.prettier, ...svelte.configs["flat/prettier"],
{ {
languageOptions: { languageOptions: {
globals: { globals: {
@@ -42,35 +23,13 @@ export default defineConfig(
...globals.node, ...globals.node,
}, },
}, },
settings: {
tailwindcss: {
cssConfigPath: "./src/app.css",
},
},
rules: {
"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"],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
parser: ts.parser, parser: ts.parser,
svelteConfig,
}, },
}, },
}, },
-18
View File
@@ -1,18 +0,0 @@
import { defineConfig } from "kysely-ctl";
import { createDialect } from "./src/lib/server/db/dialect.ts";
// 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({
dialect: createDialect({
host: optional("DATABASE_HOST"),
port: optional("DATABASE_PORT") ? Number(optional("DATABASE_PORT")) : undefined,
user: optional("DATABASE_USER"),
password: optional("DATABASE_PASSWORD"),
name: optional("DATABASE_NAME"),
}),
migrations: {
migrationFolder: "./src/lib/server/db/migrations",
},
});
+40 -62
View File
@@ -1,86 +1,64 @@
{ {
"name": "arkvault", "name": "arkvault",
"private": true, "private": true,
"version": "0.9.1", "version": "0.2.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", "db:push": "drizzle-kit push",
"test:watch": "vitest", "db:generate": "drizzle-kit generate",
"test:e2e": "bash test/e2e/run.sh", "db:migrate": "drizzle-kit migrate",
"db:up": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d", "db:studio": "drizzle-kit studio"
"db:down": "docker compose -f docker-compose.dev.yaml -p arkvault-dev down",
"db:migrate": "kysely migrate"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.1.1", "@eslint/compat": "^1.2.4",
"@eslint/js": "^10.0.1", "@iconify-json/material-symbols": "^1.2.12",
"@iconify-json/material-symbols": "^1.2.95", "@sveltejs/adapter-node": "^5.2.11",
"@noble/hashes": "^2.4.0", "@sveltejs/kit": "^2.15.2",
"@playwright/test": "1.64.0", "@sveltejs/vite-plugin-svelte": "^4.0.4",
"@sveltejs/adapter-node": "^6.0.0", "@types/better-sqlite3": "^7.6.12",
"@sveltejs/kit": "^3.0.1",
"@sveltejs/load-config": "^0.2.2",
"@sveltejs/vite-plugin-svelte": "^7.3.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.7",
"@types/pg": "^8.23.1", "autoprefixer": "^10.4.20",
"axios": "^1.20.0", "dexie": "^4.0.10",
"dexie": "^4.4.6", "drizzle-kit": "^0.22.8",
"es-hangul": "^2.4.0", "eslint": "^9.17.0",
"eslint": "^10.12.0", "eslint-config-prettier": "^9.1.0",
"eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^2.46.1",
"eslint-plugin-svelte": "^3.23.1", "eslint-plugin-tailwindcss": "^3.17.5",
"eslint-plugin-tailwindcss": "^4.4.0",
"exifreader": "^4.48.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"globals": "^17.13.0", "globals": "^15.14.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"kysely-ctl": "^0.21.0", "mime": "^4.0.6",
"lru-cache": "^11.5.3", "prettier": "^3.4.2",
"mime": "^4.1.0", "prettier-plugin-svelte": "^3.3.2",
"p-limit": "^7.3.3", "prettier-plugin-tailwindcss": "^0.6.9",
"prettier": "^3.9.10", "svelte": "^5.17.1",
"prettier-plugin-svelte": "^4.1.1", "svelte-check": "^4.1.3",
"prettier-plugin-tailwindcss": "^0.8.1", "tailwindcss": "^3.4.17",
"svelte": "^5.57.2", "typescript": "^5.7.3",
"svelte-check": "^4.7.6", "typescript-eslint": "^8.19.1",
"tailwindcss": "^4.3.3", "unplugin-icons": "^0.22.0",
"typescript": "^6.0.3", "vite": "^5.4.11"
"typescript-eslint": "^8.71.1",
"unplugin-icons": "^24.0.0",
"vite": "^8.3.4",
"vitest": "^5.0.3"
}, },
"dependencies": { "dependencies": {
"@trpc/server": "^11.19.0", "argon2": "^0.41.1",
"argon2": "^0.45.1", "better-sqlite3": "^11.7.2",
"kysely": "^0.29.6", "drizzle-orm": "^0.33.0",
"ms": "^2.1.3", "ms": "^2.1.3",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"pg": "^8.23.1", "uuid": "^11.0.4",
"superjson": "^2.2.6", "zod": "^3.24.1"
"zod": "^4.6.5"
}, },
"engines": { "engines": {
"node": "^22.17.0", "node": "^22.0.0",
"pnpm": "^12.0.0" "pnpm": "^9.0.0"
},
"imports": {
"#lib/*": "./src/lib/*",
"#trpc/*": "./src/trpc/*",
"#workers/*": "./src/workers/*"
} }
} }
+2457 -3274
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);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="ko"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
-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") },
});
+27 -17
View File
@@ -1,23 +1,33 @@
import type { ClientInit } from "@sveltejs/kit/hooks"; import type { ClientInit } from "@sveltejs/kit";
import { cleanupFileCaches } from "#lib/client/cache/index.ts"; import { getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
import { keyring } from "#lib/client/keys/index.ts"; import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
import { listenForServiceWorkerMessages } from "#lib/client/service-worker.ts";
import { cleanupDanglingInfos } from "#lib/client/storage/indexed-db/index.ts";
const requestPersistentStorage = async () => { const prepareClientKeyStore = async () => {
const isPersistent = await navigator.storage.persist(); const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([
if (isPersistent) { getClientKey("encrypt"),
console.log("[ArkVault] Persistent storage granted."); getClientKey("decrypt"),
} else { getClientKey("sign"),
console.warn("[ArkVault] Persistent storage not granted."); getClientKey("verify"),
]);
if (encryptKey && decryptKey && signKey && verifyKey) {
clientKeyStore.set({ encryptKey, decryptKey, signKey, verifyKey });
}
};
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 () => { export const init: ClientInit = async () => {
listenForServiceWorkerMessages(); await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore(), prepareHmacSecretStore()]);
await Promise.all([requestPersistentStorage(), keyring.load()]);
cleanupDanglingInfos(); // Intended
cleanupFileCaches().catch(console.error);
}; };
+14 -22
View File
@@ -1,30 +1,22 @@
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/drizzle";
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";
export const init: ServerInit = async () => { export const init: ServerInit = () => {
await migrateDB(); 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(),
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`);
};
-29
View File
@@ -1,29 +0,0 @@
import type { ComputeMessage, ResultMessage } from "#workers/hmac.ts";
import HmacWorker from "#workers/hmac.ts?worker";
// Computes HMAC of large blobs incrementally in a worker, which WebCrypto doesn't support
export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => {
const stream = message.stream();
const hmacSecretRaw = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret));
const worker = new HmacWorker();
return new Promise<Uint8Array>((resolve, reject) => {
worker.onmessage = ({ data }: MessageEvent<ResultMessage>) => {
if ("result" in data) {
resolve(data.result);
} else {
reject(data.error);
}
worker.terminate();
};
worker.onerror = ({ error }) => {
reject(error);
worker.terminate();
};
worker.postMessage({ stream, key: hmacSecretRaw } satisfies ComputeMessage, {
transfer: [stream, hmacSecretRaw.buffer],
});
});
};
-88
View File
@@ -1,88 +0,0 @@
import {
concatenateBuffers,
digestMessage,
exportRSAKey,
exportRSAKeyToBase64,
generateEncryptionKeyPair,
generateHmacSecret,
generateMasterKey,
generateSigningKeyPair,
importEncryptionKeyPairFromBase64,
importSigningKeyPairFromBase64,
makeAESKeyNonextractable,
makeRSAKeyNonextractable,
wrapHmacSecret,
wrapMasterKey,
} from "#lib/shared/crypto/index.ts";
import { keyring } from "./keyring.svelte.ts";
import { deserializeClientKeys } from "./serialization.ts";
// Generates new client keys and keeps them in the keyring without persisting
export const generateClientKeys = async () => {
const { encryptKey, decryptKey } = await generateEncryptionKeyPair();
const { signKey, verifyKey } = await generateSigningKeyPair();
keyring.setClientKeys({
encryptKey,
decryptKey: await makeRSAKeyNonextractable(decryptKey),
signKey: await makeRSAKeyNonextractable(signKey),
verifyKey,
});
return {
encryptKey,
encryptKeyBase64: await exportRSAKeyToBase64(encryptKey),
decryptKeyBase64: await exportRSAKeyToBase64(decryptKey),
signKeyBase64: await exportRSAKeyToBase64(signKey),
verifyKeyBase64: await exportRSAKeyToBase64(verifyKey),
};
};
export const importClientKeys = async (clientKeysSerialized: string) => {
const clientKeys = deserializeClientKeys(clientKeysSerialized);
if (!clientKeys) return false;
const { encryptKey, decryptKey } = await importEncryptionKeyPairFromBase64(
clientKeys.encryptKeyBase64,
clientKeys.decryptKeyBase64,
);
const { signKey, verifyKey } = await importSigningKeyPairFromBase64(
clientKeys.signKeyBase64,
clientKeys.verifyKeyBase64,
);
keyring.setClientKeys({
encryptKey,
decryptKey: await makeRSAKeyNonextractable(decryptKey),
signKey: await makeRSAKeyNonextractable(signKey),
verifyKey,
});
return true;
};
export const generateInitialMasterKey = async (encryptKey: CryptoKey) => {
const { masterKey } = await generateMasterKey();
return {
masterKey: await makeAESKeyNonextractable(masterKey),
masterKeyWrapped: await wrapMasterKey(masterKey, encryptKey),
};
};
export const generateInitialHmacSecret = async (masterKey: CryptoKey) => {
const { hmacSecret } = await generateHmacSecret();
return {
hmacSecretWrapped: await wrapHmacSecret(hmacSecret, masterKey),
};
};
export const generatePublicKeyFingerprint = async (encryptKey: CryptoKey, verifyKey: CryptoKey) => {
const { key: encryptKeyBuffer } = await exportRSAKey(encryptKey);
const { key: verifyKeyBuffer } = await exportRSAKey(verifyKey);
const digest = await digestMessage(concatenateBuffers(encryptKeyBuffer, verifyKeyBuffer));
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
.toUpperCase()
.match(/.{1,4}/g)!
.join(" ");
};
-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();
-54
View File
@@ -1,54 +0,0 @@
import { z } from "zod";
const SerializedClientKeysSchema = z.intersection(
z.object({
generator: z.literal("ArkVault"),
exportedAt: z.iso.datetime(),
}),
z.object({
version: z.literal(1),
encryptKey: z.base64().nonempty(),
decryptKey: z.base64().nonempty(),
signKey: z.base64().nonempty(),
verifyKey: z.base64().nonempty(),
}),
);
type SerializedClientKeys = z.infer<typeof SerializedClientKeysSchema>;
type DeserializedClientKeys = {
encryptKeyBase64: string;
decryptKeyBase64: string;
signKeyBase64: string;
verifyKeyBase64: string;
};
export const serializeClientKeys = ({
encryptKeyBase64,
decryptKeyBase64,
signKeyBase64,
verifyKeyBase64,
}: DeserializedClientKeys) => {
return JSON.stringify({
version: 1,
generator: "ArkVault",
exportedAt: new Date().toISOString(),
encryptKey: encryptKeyBase64,
decryptKey: decryptKeyBase64,
signKey: signKeyBase64,
verifyKey: verifyKeyBase64,
} satisfies SerializedClientKeys);
};
export const deserializeClientKeys = (serialized: string) => {
const zodRes = SerializedClientKeysSchema.safeParse(JSON.parse(serialized));
if (zodRes.success) {
return {
encryptKeyBase64: zodRes.data.encryptKey,
decryptKeyBase64: zodRes.data.decryptKey,
signKeyBase64: zodRes.data.signKey,
verifyKeyBase64: zodRes.data.verifyKey,
} satisfies DeserializedClientKeys;
}
return undefined;
};
-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;
};
-149
View File
@@ -1,149 +0,0 @@
import { convertHeic } from "./heic.ts";
const scaleSize = (width: number, height: number, targetSize: number) => {
if (width <= targetSize || height <= targetSize) {
return { width, height };
}
const scale = targetSize / Math.min(width, height);
return {
width: Math.round(width * scale),
height: Math.round(height * scale),
};
};
const capture = (
width: number,
height: number,
drawer: (context: CanvasRenderingContext2D, width: number, height: number) => void,
targetSize = 250,
) => {
return new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement("canvas");
const { width: scaledWidth, height: scaledHeight } = scaleSize(width, height, targetSize);
canvas.width = scaledWidth;
canvas.height = scaledHeight;
const context = canvas.getContext("2d");
if (!context) {
return reject(new Error("Failed to generate thumbnail"));
}
drawer(context, scaledWidth, scaledHeight);
canvas.toBlob((blob) => {
if (blob && blob.type === "image/webp") {
resolve(blob);
} else {
reject(new Error("Failed to generate thumbnail"));
}
}, "image/webp");
});
};
const generateImageThumbnail = (imageUrl: string) => {
return new Promise<Blob>((resolve, reject) => {
const image = new Image();
image.onload = () => {
capture(image.width, image.height, (context, width, height) => {
context.drawImage(image, 0, 0, width, height);
})
.then(resolve)
.catch(reject);
};
image.onerror = reject;
image.src = imageUrl;
});
};
export const captureVideoThumbnail = (video: HTMLVideoElement) => {
return capture(video.videoWidth, video.videoHeight, (context, width, height) => {
context.drawImage(video, 0, 0, width, height);
});
};
const VIDEO_THUMBNAIL_TIMEOUT_MS = 30_000;
const generateVideoThumbnail = (videoUrl: string, time = 0) => {
return new Promise<Blob>((resolve, reject) => {
const video = document.createElement("video");
let isCaptured = false;
const cleanup = () => {
clearTimeout(timeout);
video.onloadedmetadata = video.onloadeddata = video.onseeked = video.onerror = null;
video.removeAttribute("src");
video.load(); // Stops loading the video
};
const fail = (reason: unknown) => {
cleanup();
reject(reason);
};
const captureFrame = () => {
if (isCaptured || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
isCaptured = true;
captureVideoThumbnail(video).then(resolve, reject).finally(cleanup);
};
// Streamed videos may stall without any error
const timeout = setTimeout(
() => fail(new Error("Timed out while loading the video")),
VIDEO_THUMBNAIL_TIMEOUT_MS,
);
video.onloadedmetadata = () => {
if (video.videoWidth === 0 || video.videoHeight === 0) {
return fail(new Error("Invalid video size"));
}
// Frame callbacks aren't guaranteed for videos not in the document, so also wait for seeking
video.requestVideoFrameCallback(captureFrame);
video.onseeked = captureFrame;
if (time === 0) {
video.onloadeddata = captureFrame;
}
video.currentTime = Math.min(time, video.duration);
};
video.onerror = () => fail(video.error);
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.src = videoUrl;
});
};
// Generates a thumbnail of the image or video at the URL. Returns `null` if not supported.
export const generateThumbnailFromUrl = async (url: string, contentType: string) => {
try {
if (contentType.startsWith("image/")) {
try {
return await generateImageThumbnail(url);
} catch {
if (contentType !== "image/heic") return null;
const blob = await (await fetch(url)).blob();
const png = URL.createObjectURL(await convertHeic(blob, "image/png"));
try {
return await generateImageThumbnail(png);
} finally {
URL.revokeObjectURL(png);
}
}
} else if (contentType.startsWith("video/")) {
return await generateVideoThumbnail(url);
}
return null;
} catch {
return null;
}
};
export const generateThumbnail = async (blob: Blob) => {
const url = URL.createObjectURL(blob);
try {
return await generateThumbnailFromUrl(url, blob.type);
} finally {
URL.revokeObjectURL(url);
}
};
-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,212 +0,0 @@
import { Dexie, type EntityTable } from "dexie";
interface DirectoryInfo {
id: number;
parentId: DirectoryId;
name: string;
isFavorite: boolean;
}
interface FileInfo {
id: number;
parentId: DirectoryId;
name: string;
contentType: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds?: number[];
isFavorite: boolean;
}
interface CategoryInfo {
id: number;
parentId: CategoryId;
name: string;
files?: { id: number; isRecursive: boolean }[];
isFileRecursive?: boolean;
}
const filesystem = new Dexie("filesystem") as Dexie & {
directory: EntityTable<DirectoryInfo, "id">;
file: EntityTable<FileInfo, "id">;
category: EntityTable<CategoryInfo, "id">;
};
filesystem
.version(3)
.stores({
directory: "id, parentId",
file: "id, parentId",
category: "id, parentId",
})
.upgrade(async (trx) => {
await trx
.table("category")
.toCollection()
.modify((category) => {
category.isFileRecursive = false;
});
});
filesystem.version(4).upgrade(async (trx) => {
await Promise.all([
trx
.table("directory")
.toCollection()
.modify((directory) => {
directory.isFavorite = false;
}),
trx
.table("file")
.toCollection()
.modify((file) => {
file.isFavorite = false;
}),
]);
});
export const getDirectoryInfos = async (parentId: DirectoryId) => {
return await filesystem.directory.where({ parentId }).toArray();
};
export const getDirectoryInfo = async (id: number) => {
return await filesystem.directory.get(id);
};
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
await filesystem.directory.upsert(directoryInfo.id, {
parentId: directoryInfo.parentId,
name: directoryInfo.name,
isFavorite: directoryInfo.isFavorite,
});
};
export const deleteDirectoryInfo = async (id: number) => {
await filesystem.directory.delete(id);
};
export const deleteDanglingDirectoryInfos = async (
parentId: DirectoryId,
validIds: Set<number>,
) => {
await filesystem.directory
.where({ parentId })
.and((directory) => !validIds.has(directory.id))
.delete();
};
export const getAllFileInfos = async () => {
return await filesystem.file.toArray();
};
export const getFileInfos = async (parentId: DirectoryId) => {
return await filesystem.file.where({ parentId }).toArray();
};
export const getFileInfo = async (id: number) => {
return await filesystem.file.get(id);
};
export const bulkGetFileInfos = async (ids: number[]) => {
return await filesystem.file.bulkGet(ids);
};
export const storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.upsert(fileInfo.id, {
parentId: fileInfo.parentId,
name: fileInfo.name,
contentType: fileInfo.contentType,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
categoryIds: fileInfo.categoryIds,
isFavorite: fileInfo.isFavorite,
});
};
export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id);
};
export const bulkDeleteFileInfos = async (ids: number[]) => {
await filesystem.file.bulkDelete(ids);
};
export const deleteDanglingFileInfos = async (parentId: DirectoryId, validIds: Set<number>) => {
await filesystem.file
.where({ parentId })
.and((file) => !validIds.has(file.id))
.delete();
};
export const getCategoryInfos = async (parentId: CategoryId) => {
return await filesystem.category.where({ parentId }).toArray();
};
export const getCategoryInfo = async (id: number) => {
return await filesystem.category.get(id);
};
export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.upsert(categoryInfo.id, {
parentId: categoryInfo.parentId,
name: categoryInfo.name,
files: categoryInfo.files,
isFileRecursive: categoryInfo.isFileRecursive,
});
};
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
await filesystem.category.update(id, changes);
};
export const deleteCategoryInfo = async (id: number) => {
await filesystem.category.delete(id);
};
export const deleteDanglingCategoryInfos = async (parentId: CategoryId, validIds: Set<number>) => {
await filesystem.category
.where({ parentId })
.and((category) => !validIds.has(category.id))
.delete();
};
export const cleanupDanglingInfos = async () => {
const validDirectoryIds: number[] = [];
const validFileIds: number[] = [];
const directoryQueue: DirectoryId[] = ["root"];
while (true) {
const directoryId = directoryQueue.shift();
if (!directoryId) break;
const [subDirectories, files] = await Promise.all([
filesystem.directory.where({ parentId: directoryId }).toArray(),
filesystem.file.where({ parentId: directoryId }).toArray(),
]);
subDirectories.forEach(({ id }) => {
validDirectoryIds.push(id);
directoryQueue.push(id);
});
files.forEach(({ id }) => validFileIds.push(id));
}
const validCategoryIds: number[] = [];
const categoryQueue: CategoryId[] = ["root"];
while (true) {
const categoryId = categoryQueue.shift();
if (!categoryId) break;
const subCategories = await filesystem.category.where({ parentId: categoryId }).toArray();
subCategories.forEach(({ id }) => {
validCategoryIds.push(id);
categoryQueue.push(id);
});
}
await Promise.all([
filesystem.directory.where("id").noneOf(validDirectoryIds).delete(),
filesystem.file.where("id").noneOf(validFileIds).delete(),
filesystem.category.where("id").noneOf(validCategoryIds).delete(),
]);
};
@@ -1,3 +0,0 @@
export * from "./cache-index.ts";
export * from "./filesystem.ts";
export * from "./key-store.ts";
-144
View File
@@ -1,144 +0,0 @@
const getFileHandle = async (path: string, create = true) => {
if (path[0] !== "/") {
throw new Error("Path must be absolute");
}
const parts = path.split("/");
if (parts.length <= 1) {
throw new Error("Invalid path");
}
try {
let directoryHandle = await navigator.storage.getDirectory();
for (const part of parts.slice(0, -1)) {
if (!part) continue;
directoryHandle = await directoryHandle.getDirectoryHandle(part, { create });
}
const filename = parts[parts.length - 1]!;
const fileHandle = await directoryHandle.getFileHandle(filename, { create });
return { parentHandle: directoryHandle, filename, fileHandle };
} catch (e) {
if (e instanceof DOMException && e.name === "NotFoundError") {
return {};
}
throw e;
}
};
export const getFile = async (path: string) => {
const { fileHandle } = await getFileHandle(path, false);
if (!fileHandle) return null;
return await fileHandle.getFile();
};
export const readFile = async (path: string) => {
return (await getFile(path))?.arrayBuffer() ?? null;
};
export const writeFile = async (path: string, data: ArrayBuffer) => {
const { fileHandle } = await getFileHandle(path);
const writable = await fileHandle!.createWritable();
try {
await writable.write(data);
} finally {
await writable.close();
}
};
// Changes are applied atomically when the stream is closed, and discarded if it is aborted
export const createWritable = async (path: string) => {
const { fileHandle } = await getFileHandle(path);
return await fileHandle!.createWritable();
};
export const deleteFile = async (path: string) => {
const { parentHandle, filename } = await getFileHandle(path, false);
if (!parentHandle) return;
await parentHandle.removeEntry(filename);
};
const getDirectoryHandle = async (path: string) => {
if (path[0] !== "/") {
throw new Error("Path must be absolute");
}
const parts = path.split("/");
if (parts.length <= 1) {
throw new Error("Invalid path");
}
try {
let directoryHandle = await navigator.storage.getDirectory();
let parentHandle;
for (const part of parts.slice(1)) {
if (!part) continue;
parentHandle = directoryHandle;
directoryHandle = await directoryHandle.getDirectoryHandle(part);
}
return { directoryHandle, parentHandle };
} catch (e) {
if (e instanceof DOMException && e.name === "NotFoundError") {
return {};
}
throw e;
}
};
export const deleteDirectory = async (path: string) => {
const { directoryHandle, parentHandle } = await getDirectoryHandle(path);
if (!parentHandle) return;
await parentHandle.removeEntry(directoryHandle.name, { recursive: true });
};
const listEntryNames = async (path: string, kind: FileSystemHandleKind) => {
const { directoryHandle } = await getDirectoryHandle(path);
if (!directoryHandle) return [];
const names: string[] = [];
for await (const handle of directoryHandle.values()) {
if (handle.kind === kind) {
names.push(handle.name);
}
}
return names;
};
export const listFileNames = (path: string) => listEntryNames(path, "file");
export const listDirectoryNames = (path: string) => listEntryNames(path, "directory");
// Deletes the files (not directories) directly under the directory
export const deleteFilesInDirectory = async (path: string) => {
const { directoryHandle } = await getDirectoryHandle(path);
if (!directoryHandle) return;
for (const name of await listFileNames(path)) {
await directoryHandle.removeEntry(name);
}
};
// Renames the file atomically
export const moveFile = async (path: string, newPath: string) => {
const { fileHandle } = await getFileHandle(path, false);
if (!fileHandle) {
throw new Error("File not found");
}
let directoryHandle = await navigator.storage.getDirectory();
const parts = newPath.split("/").filter((part) => part);
for (const part of parts.slice(0, -1)) {
directoryHandle = await directoryHandle.getDirectoryHandle(part, { create: true });
}
// Supported by Chromium for the origin private file system
await (
fileHandle as FileSystemFileHandle & {
move(destination: FileSystemDirectoryHandle, name: string): Promise<void>;
}
).move(directoryHandle, parts.at(-1)!);
};
-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";
-259
View File
@@ -1,259 +0,0 @@
import { limitFunction } from "p-limit";
import { createUploadFileCache } from "#lib/client/cache/index.ts";
import { signMessageHmac } from "#lib/client/crypto/hmac.ts";
import {
ensureHmacSecrets,
keyring,
type HmacSecret,
type MasterKey,
} from "#lib/client/keys/index.ts";
import { readExifDateTime } from "#lib/client/media/exif.ts";
import { generateThumbnail } from "#lib/client/media/thumbnail.ts";
import { uploadBlob } from "#lib/client/transfer/chunked-upload.ts";
import {
encodeToBase64,
generateDataKey,
wrapDataKey,
encryptString,
} from "#lib/shared/crypto/index.ts";
import { getChunkCount } from "#lib/shared/format/index.ts";
import { Scheduler } from "#lib/utils/index.ts";
import { trpc } from "#trpc/client.ts";
const LIVE_STATUSES = [
"queued",
"encryption-pending",
"encrypting",
"upload-pending",
"uploading",
] as const;
type LiveStatus = (typeof LIVE_STATUSES)[number];
export interface FileUploadState {
id: string;
name: string;
parentId: DirectoryId;
status: LiveStatus | "uploaded" | "canceled" | "error";
progress?: number;
rate?: number;
estimated?: number;
}
export type LiveFileUploadState = FileUploadState & { status: LiveStatus };
const scheduler = new Scheduler<{ fileId: number; thumbnailBuffer?: ArrayBuffer } | undefined>();
let uploadingFiles: FileUploadState[] = $state([]);
const isFileUploading = (file: FileUploadState): file is LiveFileUploadState => {
return (LIVE_STATUSES as readonly string[]).includes(file.status);
};
export const getUploadingFiles = (parentId?: DirectoryId) => {
return uploadingFiles
.filter(isFileUploading)
.filter((file) => parentId === undefined || file.parentId === parentId);
};
export const clearUploadedFiles = () => {
uploadingFiles = uploadingFiles.filter(isFileUploading);
};
const requestDuplicateFileScan = limitFunction(
async (
state: FileUploadState,
file: File,
hmacSecret: HmacSecret,
onDuplicate: () => Promise<boolean>,
) => {
state.status = "encryption-pending";
const fileSigned = encodeToBase64(await signMessageHmac(file, hmacSecret.secret));
const files = await trpc.file.listByHash.query({
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
});
if (files.length === 0 || (await onDuplicate())) {
return { fileSigned };
} else {
return {};
}
},
{ concurrency: 1 },
);
const getFileType = (file: File) => {
if (file.type) return file.type;
if (file.name.endsWith(".heic")) return "image/heic";
throw new Error("Unknown file type");
};
interface FileMetadata {
parentId: "root" | number;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
}
const requestFileMetadataEncryption = limitFunction(
async (
state: FileUploadState,
file: Blob,
fileMetadata: FileMetadata,
masterKey: MasterKey,
hmacSecret: HmacSecret,
) => {
state.status = "encrypting";
const { dataKey, dataKeyVersion } = await generateDataKey();
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
const [nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, thumbnailBuffer] =
await Promise.all([
encryptString(fileMetadata.name, dataKey),
fileMetadata.createdAt &&
encryptString(fileMetadata.createdAt.getTime().toString(), dataKey),
encryptString(fileMetadata.lastModifiedAt.getTime().toString(), dataKey),
generateThumbnail(file).then((blob) => blob?.arrayBuffer()),
]);
const { uploadId } = await trpc.upload.startFileUpload.mutate({
chunks: getChunkCount(file.size),
parent: fileMetadata.parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion,
hskVersion: hmacSecret.version,
contentType: file.type,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
});
state.status = "upload-pending";
return { uploadId, thumbnailBuffer, dataKey, dataKeyVersion };
},
{ concurrency: 4 },
);
const requestFileUpload = limitFunction(
async (
state: FileUploadState,
uploadId: string,
file: Blob,
fileSigned: string,
thumbnailBuffer: ArrayBuffer | undefined,
dataKey: CryptoKey,
dataKeyVersion: Date,
) => {
state.status = "uploading";
// The file is cached while uploading, so that it doesn't have to be read again
const cache = createUploadFileCache(file.size);
let fileId;
try {
const { encContentHash } = await uploadBlob(uploadId, file, dataKey, {
onProgress(s) {
state.progress = s.progress;
state.rate = s.rate;
},
onPlaintextChunk: cache.writeChunk,
});
({ file: fileId } = await trpc.upload.completeFileUpload.mutate({
uploadId,
contentHmac: fileSigned,
encContentHash,
}));
} catch (e) {
await cache.discard();
throw e;
}
await cache.commit(fileId).catch(console.error);
if (thumbnailBuffer) {
try {
const { uploadId } = await trpc.upload.startFileThumbnailUpload.mutate({
file: fileId,
dekVersion: dataKeyVersion,
});
await uploadBlob(uploadId, new Blob([thumbnailBuffer]), dataKey);
await trpc.upload.completeFileThumbnailUpload.mutate({ uploadId });
} catch (e) {
console.error(e);
}
}
state.status = "uploaded";
return { fileId };
},
{ concurrency: 1 },
);
export const uploadFile = async (
file: File,
parentId: "root" | number,
onDuplicate: () => Promise<boolean>,
) => {
uploadingFiles.push({
id: crypto.randomUUID(),
name: file.name,
parentId,
status: "queued",
});
const state = uploadingFiles.at(-1)!;
return await scheduler.schedule(file.size, async () => {
try {
await ensureHmacSecrets();
const masterKey = keyring.activeMasterKey;
const hmacSecret = keyring.activeHmacSecret;
const { fileSigned } = await requestDuplicateFileScan(state, file, hmacSecret, onDuplicate);
if (!fileSigned) {
state.status = "canceled";
uploadingFiles = uploadingFiles.filter((file) => file !== state);
return;
}
const fileType = getFileType(file);
const fileMetadata: FileMetadata = {
parentId,
name: file.name,
lastModifiedAt: new Date(file.lastModified),
};
if (fileType.startsWith("image/")) {
fileMetadata.createdAt = await readExifDateTime(file);
}
const blob = new Blob([file], { type: fileType });
const { uploadId, thumbnailBuffer, dataKey, dataKeyVersion } =
await requestFileMetadataEncryption(state, blob, fileMetadata, masterKey, hmacSecret);
const { fileId } = await requestFileUpload(
state,
uploadId,
blob,
fileSigned,
thumbnailBuffer,
dataKey,
dataKeyVersion,
);
return { fileId, thumbnailBuffer };
} catch (e) {
state.status = "error";
throw e;
}
});
};
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { fade, fly } from "svelte/transition";
import { AdaptiveDiv } from "$lib/components/divs";
interface Props {
children: Snippet;
onclose?: () => void;
isOpen: boolean;
}
let { children, onclose, isOpen = $bindable() }: Props = $props();
const closeBottomSheet = $derived(
onclose ||
(() => {
isOpen = false;
}),
);
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={closeBottomSheet} class="fixed inset-0 z-10 flex items-end justify-center">
<div class="absolute inset-0 bg-black bg-opacity-50" transition:fade={{ duration: 100 }}></div>
<div class="z-20 w-full">
<AdaptiveDiv>
<div
onclick={(e) => e.stopPropagation()}
class="flex max-h-[70vh] min-h-[30vh] rounded-t-2xl bg-white px-4"
transition:fly={{ y: 100, duration: 200 }}
>
{@render children?.()}
</div>
</AdaptiveDiv>
</div>
</div>
{/if}
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { fade } from "svelte/transition";
import { AdaptiveDiv } from "$lib/components/divs";
interface Props {
children: Snippet;
onclose?: () => void;
isOpen: boolean;
}
let { children, onclose, isOpen = $bindable() }: Props = $props();
const closeModal = $derived(
onclose ||
(() => {
isOpen = false;
}),
);
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={closeModal}
class="fixed inset-0 z-10 bg-black bg-opacity-50"
transition:fade={{ duration: 100 }}
>
<AdaptiveDiv>
<div class="flex h-full items-center justify-center px-4">
<div onclick={(e) => e.stopPropagation()} class="rounded-2xl bg-white p-4">
{@render children?.()}
</div>
</div>
</AdaptiveDiv>
</div>
{/if}
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from "svelte";
import IconArrowBack from "~icons/material-symbols/arrow-back";
interface Props {
children?: Snippet;
onback?: () => void;
title?: string;
}
let { children, onback, title }: Props = $props();
const back = $derived(() => {
setTimeout(onback || (() => history.back()), 100);
});
</script>
<div class="sticky top-0 z-10 flex flex-shrink-0 items-center justify-between bg-white py-4">
<button onclick={back} class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-gray-100">
<IconArrowBack class="text-2xl" />
</button>
{#if title}
<p class="flex-grow truncate px-2 text-center text-lg font-semibold">{title}</p>
{/if}
<div class="w-[2.3rem] flex-shrink-0">
{#if children}
{@render children?.()}
{/if}
</div>
</div>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
color?: "primary" | "gray";
onclick?: () => void;
}
let { children, color = "primary", onclick }: Props = $props();
const bgColorStyle = $derived(
{
primary: "bg-primary-600 active:bg-primary-500",
gray: "bg-gray-300 active:bg-gray-400",
}[color],
);
const fontColorStyle = $derived(
{
primary: "text-white",
gray: "text-gray-800",
}[color],
);
</script>
<button
onclick={() => {
setTimeout(() => {
onclick?.();
}, 100);
}}
class="{bgColorStyle} {fontColorStyle} h-12 w-full min-w-fit rounded-xl font-medium"
>
<div class="h-full w-full p-3 transition active:scale-95">
{@render children?.()}
</div>
</button>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Snippet } from "svelte";
import IconChevronRight from "~icons/material-symbols/chevron-right";
interface Props {
children: Snippet;
onclick?: () => void;
}
let { children, onclick }: Props = $props();
</script>
<button
onclick={() => {
setTimeout(() => {
onclick?.();
}, 100);
}}
class="w-full rounded-xl active:bg-gray-100"
>
<div class="flex w-full justify-between p-2 transition active:scale-95">
<div>
{@render children?.()}
</div>
<div class="flex items-center justify-center">
<IconChevronRight class="text-xl text-gray-800" />
</div>
</div>
</button>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { AdaptiveDiv } from "$lib/components/divs";
interface Props {
icon: Component<SvelteHTMLElements["svg"]>;
offset?: string;
onclick?: () => void;
}
let { icon: Icon, offset = "bottom-20", onclick }: Props = $props();
const click = () => {
setTimeout(() => {
onclick?.();
}, 100);
};
</script>
<div class="pointer-events-none fixed inset-0">
<div class="absolute w-full {offset}">
<AdaptiveDiv>
<div class="relative">
<div class="absolute bottom-4 right-4">
<button
onclick={click}
class="pointer-events-auto flex h-14 w-14 items-center justify-center rounded-full bg-gray-300 shadow-lg transition active:scale-95 active:bg-gray-400"
>
<Icon class="text-xl" />
</button>
</div>
</div>
</AdaptiveDiv>
</div>
</div>
@@ -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,10 +10,14 @@
</script> </script>
<button <button
onclick={afterPress(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 w-full p-1 transition active:scale-95">
{@render children()} {@render children?.()}
</div> </div>
</button> </button>
+4
View File
@@ -0,0 +1,4 @@
export { default as Button } from "./Button.svelte";
export { default as EntryButton } from "./EntryButton.svelte";
export { default as FloatingButton } from "./FloatingButton.svelte";
export { default as TextButton } from "./TextButton.svelte";
@@ -0,0 +1,7 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="mx-auto h-full w-full max-w-screen-md">
{@render children?.()}
</div>
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="sticky bottom-0 flex flex-col items-center gap-y-2 bg-white pb-4">
{@render children?.()}
</div>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
interface Props {
children: Snippet;
icon?: Component<SvelteHTMLElements["svg"]>;
topPadding?: boolean;
}
let { topPadding = true, children, icon: Icon }: Props = $props();
</script>
<div>
<div class="box-content flex min-h-[10vh] items-center {topPadding ? 'pt-4' : ''}">
{#if Icon}
<Icon class="text-5xl text-gray-600" />
{/if}
</div>
{@render children?.()}
</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 TitleDiv } from "./TitleDiv.svelte";
+3
View File
@@ -0,0 +1,3 @@
export { default as BottomSheet } from "./BottomSheet.svelte";
export { default as Modal } from "./Modal.svelte";
export { default as TopBar } from "./TopBar.svelte";
@@ -0,0 +1,35 @@
<script lang="ts">
interface Props {
placeholder: string;
type?: "text" | "password";
value?: string;
}
let { placeholder, type = "text", value = $bindable("") }: Props = $props();
</script>
<div class="relative mt-5">
<input
bind:value
{type}
placeholder=""
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 -->
<label
class="absolute left-0 top-1/2 -translate-y-1/2 transform text-xl text-gray-400 transition-all duration-300 ease-in-out"
>
{placeholder}
</label>
</div>
<style>
input:focus,
input:not(:placeholder-shown) {
@apply border-primary-300;
}
input:focus + label,
input:not(:placeholder-shown) + label {
@apply top-0 -translate-y-full text-sm text-primary-400;
}
</style>
+1
View File
@@ -0,0 +1 @@
export { default as TextInput } from "./TextInput.svelte";
@@ -1,22 +0,0 @@
<script lang="ts">
import { ActionModal } from "#lib/ui/index.ts";
interface Props {
isOpen: boolean;
oncancel?: () => void;
onLoginClick: () => void;
}
let { isOpen = $bindable(), oncancel, onLoginClick }: Props = $props();
</script>
<ActionModal
bind:isOpen
title="다른 디바이스에 이미 로그인되어 있어요."
cancelText="아니요"
{oncancel}
confirmText="네"
onConfirmClick={onLoginClick}
>
<p>다른 디바이스에서는 로그아웃하고, 이 디바이스에서 로그인할까요?</p>
</ActionModal>
@@ -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,42 +0,0 @@
<script module lang="ts">
import type { DataKey, SubCategoryInfo } from "#lib/features/filesystem/index.ts";
export interface SelectedCategory {
id: number;
dataKey?: DataKey;
name: string;
}
</script>
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { ActionEntryButton, CategoryLabel } from "#lib/ui/index.ts";
import { sortEntries } from "#lib/utils/index.ts";
interface Props {
categories: SubCategoryInfo[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
}
let { categories, categoryMenuIcon, onCategoryClick, onCategoryMenuClick }: Props = $props();
let categoriesWithName = $derived(sortEntries([...categories]));
</script>
{#if categoriesWithName.length > 0}
<div class="space-y-1">
{#each categoriesWithName as category (category.id)}
<ActionEntryButton
class="h-12"
onclick={() => onCategoryClick(category)}
actionButtonIcon={categoryMenuIcon}
onActionButtonClick={() => onCategoryMenuClick?.(category)}
>
<CategoryLabel name={category.name} />
</ActionEntryButton>
{/each}
</div>
{/if}
@@ -1,18 +0,0 @@
<script lang="ts">
import { TextInputModal } from "#lib/ui/index.ts";
interface Props {
isOpen: boolean;
onCreateClick: (name: string) => Promise<boolean>;
}
let { isOpen = $bindable(), onCreateClick }: Props = $props();
</script>
<TextInputModal
bind:isOpen
title="새 카테고리"
placeholder="카테고리 이름"
submitText="만들기"
onSubmitClick={onCreateClick}
/>
@@ -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,56 +0,0 @@
<script lang="ts">
import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { Categories, type SelectedCategory } from "#lib/features/filesystem/components/index.ts";
import type { CategoryInfo } from "#lib/features/filesystem/index.ts";
import { IconEntryButton } from "#lib/ui/index.ts";
import IconAddCircle from "~icons/material-symbols/add-circle";
interface Props {
class?: ClassValue;
info: CategoryInfo;
onSubCategoryClick: (subCategory: SelectedCategory) => void;
onSubCategoryCreateClick?: () => void;
onSubCategoryMenuClick?: (category: SelectedCategory) => void;
subCategoryCreatePosition?: "top" | "bottom" | "none";
subCategoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
}
let {
class: className,
info,
onSubCategoryClick,
onSubCategoryCreateClick,
onSubCategoryMenuClick,
subCategoryCreatePosition = "none",
subCategoryMenuIcon,
}: Props = $props();
</script>
<div class={["space-y-1", className]}>
{#snippet subCategoryCreate()}
<IconEntryButton
icon={IconAddCircle}
onclick={onSubCategoryCreateClick}
class="h-12 w-full"
iconClass="text-gray-600"
textClass="text-gray-700"
>
카테고리 추가하기
</IconEntryButton>
{/snippet}
{#if subCategoryCreatePosition === "top"}
{@render subCategoryCreate()}
{/if}
<Categories
categories={info.subCategories}
categoryMenuIcon={subCategoryMenuIcon}
onCategoryClick={onSubCategoryClick}
onCategoryMenuClick={onSubCategoryMenuClick}
/>
{#if subCategoryCreatePosition === "bottom"}
{@render subCategoryCreate()}
{/if}
</div>
@@ -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;
},
};
};
-86
View File
@@ -1,86 +0,0 @@
export type DataKey = { key: CryptoKey; version: Date };
export interface LocalDirectoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
name: string;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
isFavorite: boolean;
}
export interface RootDirectoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
isFavorite?: undefined;
}
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
export type MaybeDirectoryInfo =
| (DirectoryInfo & { exists: true })
| ({ id: DirectoryId; exists: false } & AllUndefined<Omit<DirectoryInfo, "id">>);
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "subDirectories" | "files">;
export interface FileInfo {
id: number;
isLegacy?: boolean;
parentId: DirectoryId;
dataKey?: DataKey;
contentType: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categories: FileCategoryInfo[];
isFavorite: boolean;
}
export type MaybeFileInfo =
| (FileInfo & { exists: true })
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
export type SummarizedFileInfo = Omit<FileInfo, "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
export interface LocalCategoryInfo {
id: number;
parentId: CategoryId;
dataKey?: DataKey;
name: string;
subCategories: SubCategoryInfo[];
files: CategoryFileInfo[];
isFileRecursive: boolean;
}
export interface RootCategoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subCategories: SubCategoryInfo[];
files?: undefined;
isFileRecursive?: undefined;
}
export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo;
export type MaybeCategoryInfo =
| (CategoryInfo & { exists: true })
| ({ id: CategoryId; exists: false } & AllUndefined<Omit<CategoryInfo, "id">>);
export type SubCategoryInfo = Omit<
LocalCategoryInfo,
"subCategories" | "files" | "isFileRecursive"
>;
export type FileCategoryInfo = Omit<SubCategoryInfo, "dataKey">;
export const isLocalDirectoryInfo = (
info: MaybeDirectoryInfo,
): info is LocalDirectoryInfo & { exists: true } => info.exists && info.id !== "root";
export const isExistingFileInfo = (info: MaybeFileInfo): info is FileInfo & { exists: true } =>
info.exists;
-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";
+11
View File
@@ -0,0 +1,11 @@
export const callGetApi = async (input: RequestInfo, fetchInternal = fetch) => {
return await fetchInternal(input);
};
export const callPostApi = async <T>(input: RequestInfo, payload?: T, fetchInternal = fetch) => {
return await fetchInternal(input, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload ? JSON.stringify(payload) : undefined,
});
};
File renamed without changes.
+2
View File
@@ -0,0 +1,2 @@
export * from "./callApi";
export * from "./gotoStateful";
@@ -70,12 +70,12 @@ export const storeMasterKeys = async (keys: MasterKey[]) => {
}; };
export const getHmacSecrets = async () => { export const getHmacSecrets = async () => {
return (await keyStore.hmacSecret.toArray()).filter(({ secret }) => secret.extractable); return await keyStore.hmacSecret.toArray();
}; };
export const storeHmacSecrets = async (secrets: HmacSecret[]) => { export const storeHmacSecrets = async (secrets: HmacSecret[]) => {
if (secrets.some(({ secret }) => !secret.extractable)) { if (secrets.some(({ secret }) => secret.extractable)) {
throw new Error("Hmac secrets must be extractable"); throw new Error("Hmac secrets must be nonextractable");
} }
await keyStore.hmacSecret.bulkPut(secrets); await keyStore.hmacSecret.bulkPut(secrets);
}; };
@@ -1,17 +1,8 @@
import { import { encodeString, decodeString, encodeToBase64, decodeFromBase64 } from "./util";
encodeString,
decodeString,
encodeToBase64,
decodeFromBase64,
concatenateBuffers,
} from "./encoding.ts";
export const AES_GCM_IV_SIZE = 12;
export const AES_GCM_TAG_SIZE = 16;
export const generateMasterKey = async () => { export const generateMasterKey = async () => {
return { return {
masterKey: await crypto.subtle.generateKey( masterKey: await window.crypto.subtle.generateKey(
{ {
name: "AES-KW", name: "AES-KW",
length: 256, length: 256,
@@ -24,7 +15,7 @@ export const generateMasterKey = async () => {
export const generateDataKey = async () => { export const generateDataKey = async () => {
return { return {
dataKey: await crypto.subtle.generateKey( dataKey: await window.crypto.subtle.generateKey(
{ {
name: "AES-GCM", name: "AES-GCM",
length: 256, length: 256,
@@ -37,24 +28,22 @@ export const generateDataKey = async () => {
}; };
export const makeAESKeyNonextractable = async (key: CryptoKey) => { export const makeAESKeyNonextractable = async (key: CryptoKey) => {
return await crypto.subtle.importKey( return await window.crypto.subtle.importKey(
"raw", "raw",
await crypto.subtle.exportKey("raw", key), await window.crypto.subtle.exportKey("raw", key),
key.algorithm, key.algorithm,
false, false,
key.usages, key.usages,
); );
}; };
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 window.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 window.crypto.subtle.unwrapKey(
"raw", "raw",
decodeFromBase64(dataKeyWrapped), decodeFromBase64(dataKeyWrapped),
masterKey, masterKey,
@@ -66,11 +55,13 @@ export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey
}; };
}; };
export const wrapHmacSecret = wrapKey; export const wrapHmacSecret = async (hmacSecret: CryptoKey, masterKey: CryptoKey) => {
return encodeToBase64(await window.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 {
hmacSecret: await crypto.subtle.unwrapKey( hmacSecret: await window.crypto.subtle.unwrapKey(
"raw", "raw",
decodeFromBase64(hmacSecretWrapped), decodeFromBase64(hmacSecretWrapped),
masterKey, masterKey,
@@ -79,15 +70,15 @@ export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: Cry
name: "HMAC", name: "HMAC",
hash: "SHA-256", hash: "SHA-256",
} satisfies HmacImportParams, } satisfies HmacImportParams,
true, // Extractable false, // Nonextractable
["sign", "verify"], ["sign", "verify"],
), ),
}; };
}; };
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 = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt( const ciphertext = await window.crypto.subtle.encrypt(
{ {
name: "AES-GCM", name: "AES-GCM",
iv, iv,
@@ -95,18 +86,14 @@ export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
dataKey, dataKey,
data, data,
); );
return { ciphertext, iv: iv.buffer }; return { ciphertext, iv: encodeToBase64(iv.buffer) };
}; };
export const decryptData = async ( export const decryptData = async (ciphertext: BufferSource, iv: string, dataKey: CryptoKey) => {
ciphertext: BufferSource, return await window.crypto.subtle.decrypt(
iv: string | BufferSource,
dataKey: CryptoKey,
) => {
return await crypto.subtle.decrypt(
{ {
name: "AES-GCM", name: "AES-GCM",
iv: typeof iv === "string" ? decodeFromBase64(iv) : iv, iv: decodeFromBase64(iv),
} satisfies AesGcmParams, } satisfies AesGcmParams,
dataKey, dataKey,
ciphertext, ciphertext,
@@ -115,22 +102,9 @@ export const decryptData = async (
export const encryptString = async (plaintext: string, dataKey: CryptoKey) => { export const encryptString = async (plaintext: string, dataKey: CryptoKey) => {
const { ciphertext, iv } = await encryptData(encodeString(plaintext), dataKey); const { ciphertext, iv } = await encryptData(encodeString(plaintext), dataKey);
return { ciphertext: encodeToBase64(ciphertext), iv: encodeToBase64(iv) }; return { ciphertext: encodeToBase64(ciphertext), iv };
}; };
export const decryptString = async (ciphertext: string, iv: string, dataKey: CryptoKey) => { export const decryptString = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return decodeString(await decryptData(decodeFromBase64(ciphertext), iv, dataKey)); return decodeString(await decryptData(decodeFromBase64(ciphertext), iv, dataKey));
}; };
export const encryptChunk = async (chunk: ArrayBuffer, dataKey: CryptoKey) => {
const { ciphertext, iv } = await encryptData(chunk, dataKey);
return concatenateBuffers(iv, ciphertext).buffer;
};
export const decryptChunk = async (encryptedChunk: ArrayBuffer, dataKey: CryptoKey) => {
return await decryptData(
encryptedChunk.slice(AES_GCM_IV_SIZE),
encryptedChunk.slice(0, AES_GCM_IV_SIZE),
dataKey,
);
};
+4
View File
@@ -0,0 +1,4 @@
export * from "./aes";
export * from "./rsa";
export * from "./sha";
export * from "./util";
@@ -1,7 +1,7 @@
import { encodeString, encodeToBase64, decodeFromBase64 } from "./encoding.ts"; import { encodeString, encodeToBase64, decodeFromBase64 } from "./util";
export const generateEncryptionKeyPair = async () => { export const generateEncryptionKeyPair = async () => {
const keyPair = await crypto.subtle.generateKey( const keyPair = await window.crypto.subtle.generateKey(
{ {
name: "RSA-OAEP", name: "RSA-OAEP",
modulusLength: 4096, modulusLength: 4096,
@@ -18,7 +18,7 @@ export const generateEncryptionKeyPair = async () => {
}; };
export const generateSigningKeyPair = async () => { export const generateSigningKeyPair = async () => {
const keyPair = await crypto.subtle.generateKey( const keyPair = await window.crypto.subtle.generateKey(
{ {
name: "RSA-PSS", name: "RSA-PSS",
modulusLength: 4096, modulusLength: 4096,
@@ -37,7 +37,7 @@ export const generateSigningKeyPair = async () => {
export const exportRSAKey = async (key: CryptoKey) => { export const exportRSAKey = async (key: CryptoKey) => {
const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const); const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const);
return { return {
key: await crypto.subtle.exportKey(format, key), key: await window.crypto.subtle.exportKey(format, key),
format, format,
}; };
}; };
@@ -46,63 +46,19 @@ export const exportRSAKeyToBase64 = async (key: CryptoKey) => {
return encodeToBase64((await exportRSAKey(key)).key); return encodeToBase64((await exportRSAKey(key)).key);
}; };
export const importEncryptionKeyPairFromBase64 = async (
encryptKeyBase64: string,
decryptKeyBase64: string,
) => {
const algorithm: RsaHashedImportParams = {
name: "RSA-OAEP",
hash: "SHA-256",
};
const encryptKey = await crypto.subtle.importKey(
"spki",
decodeFromBase64(encryptKeyBase64),
algorithm,
true,
["encrypt", "wrapKey"],
);
const decryptKey = await crypto.subtle.importKey(
"pkcs8",
decodeFromBase64(decryptKeyBase64),
algorithm,
true,
["decrypt", "unwrapKey"],
);
return { encryptKey, decryptKey };
};
export const importSigningKeyPairFromBase64 = async (
signKeyBase64: string,
verifyKeyBase64: string,
) => {
const algorithm: RsaHashedImportParams = {
name: "RSA-PSS",
hash: "SHA-256",
};
const signKey = await crypto.subtle.importKey(
"pkcs8",
decodeFromBase64(signKeyBase64),
algorithm,
true,
["sign"],
);
const verifyKey = await crypto.subtle.importKey(
"spki",
decodeFromBase64(verifyKeyBase64),
algorithm,
true,
["verify"],
);
return { signKey, verifyKey };
};
export const makeRSAKeyNonextractable = async (key: CryptoKey) => { export const makeRSAKeyNonextractable = async (key: CryptoKey) => {
const { key: exportedKey, format } = await exportRSAKey(key); const { key: exportedKey, format } = await exportRSAKey(key);
return await crypto.subtle.importKey(format, exportedKey, key.algorithm, false, key.usages); return await window.crypto.subtle.importKey(
format,
exportedKey,
key.algorithm,
false,
key.usages,
);
}; };
export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) => { export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) => {
return await crypto.subtle.decrypt( return await window.crypto.subtle.decrypt(
{ {
name: "RSA-OAEP", name: "RSA-OAEP",
} satisfies RsaOaepParams, } satisfies RsaOaepParams,
@@ -113,7 +69,7 @@ export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey)
export const wrapMasterKey = async (masterKey: CryptoKey, encryptKey: CryptoKey) => { export const wrapMasterKey = async (masterKey: CryptoKey, encryptKey: CryptoKey) => {
return encodeToBase64( return encodeToBase64(
await crypto.subtle.wrapKey("raw", masterKey, encryptKey, { await window.crypto.subtle.wrapKey("raw", masterKey, encryptKey, {
name: "RSA-OAEP", name: "RSA-OAEP",
} satisfies RsaOaepParams), } satisfies RsaOaepParams),
); );
@@ -125,7 +81,7 @@ export const unwrapMasterKey = async (
extractable = false, extractable = false,
) => { ) => {
return { return {
masterKey: await crypto.subtle.unwrapKey( masterKey: await window.crypto.subtle.unwrapKey(
"raw", "raw",
decodeFromBase64(masterKeyWrapped), decodeFromBase64(masterKeyWrapped),
decryptKey, decryptKey,
@@ -140,7 +96,7 @@ export const unwrapMasterKey = async (
}; };
export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => { export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => {
return await crypto.subtle.sign( return await window.crypto.subtle.sign(
{ {
name: "RSA-PSS", name: "RSA-PSS",
saltLength: 32, // SHA-256 saltLength: 32, // SHA-256
@@ -155,7 +111,7 @@ export const verifySignatureRSA = async (
signature: BufferSource, signature: BufferSource,
verifyKey: CryptoKey, verifyKey: CryptoKey,
) => { ) => {
return await crypto.subtle.verify( return await window.crypto.subtle.verify(
{ {
name: "RSA-PSS", name: "RSA-PSS",
saltLength: 32, // SHA-256 saltLength: 32, // SHA-256
@@ -166,21 +122,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 +140,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),
+20
View File
@@ -0,0 +1,20 @@
export const digestMessage = async (message: BufferSource) => {
return await window.crypto.subtle.digest("SHA-256", message);
};
export const generateHmacSecret = async () => {
return {
hmacSecret: await window.crypto.subtle.generateKey(
{
name: "HMAC",
hash: "SHA-256",
} satisfies HmacKeyGenParams,
true,
["sign", "verify"],
),
};
};
export const signMessageHmac = async (message: BufferSource, hmacSecret: CryptoKey) => {
return await window.crypto.subtle.sign("HMAC", hmacSecret, message);
};
@@ -9,12 +9,12 @@ export const decodeString = (data: ArrayBuffer) => {
return textDecoder.decode(data); return textDecoder.decode(data);
}; };
export const encodeToBase64 = (data: ArrayBuffer | Uint8Array) => { export const encodeToBase64 = (data: ArrayBuffer) => {
return (data instanceof ArrayBuffer ? new Uint8Array(data) : data).toBase64(); return btoa(String.fromCharCode(...new Uint8Array(data)));
}; };
export const decodeFromBase64 = (data: string) => { export const decodeFromBase64 = (data: string) => {
return Uint8Array.fromBase64(data).buffer; return Uint8Array.from(atob(data), (c) => c.charCodeAt(0)).buffer;
}; };
export const concatenateBuffers = (...buffers: ArrayBuffer[]) => { export const concatenateBuffers = (...buffers: ArrayBuffer[]) => {
+89
View File
@@ -0,0 +1,89 @@
import { writable, type Writable } from "svelte/store";
import { callGetApi } from "$lib/hooks";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
import {
directoryInfoStore,
fileInfoStore,
type DirectoryInfo,
type FileInfo,
} from "$lib/stores/file";
const fetchDirectoryInfo = async (
directoryId: "root" | number,
masterKey: CryptoKey,
infoStore: Writable<DirectoryInfo | null>,
) => {
const res = await callGetApi(`/api/directory/${directoryId}`);
if (!res.ok) throw new Error("Failed to fetch directory information");
const { metadata, subDirectories, files }: DirectoryInfoResponse = await res.json();
let newInfo: DirectoryInfo;
if (directoryId === "root") {
newInfo = {
id: "root",
subDirectoryIds: subDirectories,
fileIds: files,
};
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
newInfo = {
id: directoryId,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name: await decryptString(metadata!.name, metadata!.nameIv, dataKey),
subDirectoryIds: subDirectories,
fileIds: files,
};
}
infoStore.update(() => newInfo);
};
export const getDirectoryInfo = (directoryId: "root" | number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = directoryInfoStore.get(directoryId);
if (!info) {
info = writable(null);
directoryInfoStore.set(directoryId, info);
}
fetchDirectoryInfo(directoryId, masterKey, info);
return info;
};
const fetchFileInfo = async (
fileId: number,
masterKey: CryptoKey,
infoStore: Writable<FileInfo | null>,
) => {
const res = await callGetApi(`/api/file/${fileId}`);
if (!res.ok) throw new Error("Failed to fetch file information");
const metadata: FileInfoResponse = await res.json();
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const newInfo: FileInfo = {
id: fileId,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name: await decryptString(metadata.name, metadata.nameIv, dataKey),
};
infoStore.update(() => newInfo);
};
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = fileInfoStore.get(fileId);
if (!info) {
info = writable(null);
fileInfoStore.set(fileId, info);
}
fetchFileInfo(fileId, masterKey, info);
return info;
};
-112
View File
@@ -1,112 +0,0 @@
import { ClientRepo, SessionRepo, db } from "#lib/server/db/index.ts";
import env from "#lib/server/config.ts";
import { verifySessionId } from "#lib/server/crypto.ts";
import { AppError, invalidSessionError } from "#lib/server/errors.ts";
export interface Session {
sessionId: string;
userId: number;
clientId?: number;
}
export interface ClientSession extends Session {
clientId: number;
}
export type SessionPermission =
"any" | "notClient" | "anyClient" | "pendingClient" | "activeClient";
export class AuthenticationError extends Error {
constructor(
public status: 400 | 401,
message: string,
) {
super(message);
this.name = "AuthenticationError";
}
}
export const cookieOptions = {
path: "/",
maxAge: env.session.exp / 1000,
secure: true,
sameSite: "strict",
} as const;
export const authenticate = async (sessionIdSigned: string, ip: string, userAgent: string) => {
const sessionId = verifySessionId(sessionIdSigned, env.session.secret);
if (!sessionId) {
throw new AuthenticationError(400, "Invalid session id");
}
const session = await SessionRepo.refreshSession(db, sessionId, ip, userAgent);
if (!session) {
throw new AuthenticationError(401, "Invalid session id");
}
return {
id: sessionId,
userId: session.userId,
clientId: session.clientId ?? undefined,
};
};
const ensureUserClientState = async (
userId: number,
clientId: number | undefined,
state: "pending" | "active",
) => {
if (!clientId) {
throw new AppError("FORBIDDEN", "Forbidden");
}
const userClient = await ClientRepo.getUserClient(db, userId, clientId);
if (!userClient) {
throw invalidSessionError();
} else if (userClient.state !== state) {
throw new AppError("FORBIDDEN", "Forbidden");
}
};
export async function authorize(
locals: App.Locals,
requiredPermission: "any" | "notClient",
): Promise<Session>;
export async function authorize(
locals: App.Locals,
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: SessionPermission,
): Promise<Session> {
if (!locals.session) {
throw new AppError("UNAUTHORIZED", "Unauthenticated");
}
const { id: sessionId, userId, clientId } = locals.session;
switch (requiredPermission) {
case "any":
break;
case "notClient":
if (clientId) {
throw new AppError("FORBIDDEN", "Forbidden");
}
break;
case "anyClient":
if (!clientId) {
throw new AppError("FORBIDDEN", "Forbidden");
}
break;
case "pendingClient":
await ensureUserClientState(userId, clientId, "pending");
break;
case "activeClient":
await ensureUserClientState(userId, clientId, "active");
break;
}
return { sessionId, userId, clientId };
}
Loaded 100 of 408 files, more files were not shown because too many files have changed in this diff. Show more