2 Commits

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
257 changed files with 7101 additions and 10348 deletions

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
@@ -30,3 +27,6 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# SQLite
*.db

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=

7
.gitignore vendored
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
@@ -28,3 +26,6 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# SQLite
*.db

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

View File

@@ -2,7 +2,7 @@
FROM node:22-alpine AS base FROM node:22-alpine AS base
WORKDIR /app WORKDIR /app
RUN npm install -g pnpm@10 RUN npm install -g pnpm@9
COPY pnpm-lock.yaml . COPY pnpm-lock.yaml .
# Build Stage # Build Stage
@@ -10,9 +10,8 @@ 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"]

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`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.|

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:

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
drizzle.config.ts Normal file
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",
});

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

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1736704436996,
"tag": "0000_unknown_stark_industries",
"breakpoints": true
}
]
}

View File

@@ -1,24 +1,21 @@
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
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";
import svelteConfig from "./svelte.config.js";
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url)); const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default defineConfig( export default ts.config(
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["flat/recommended"], ...tailwind.configs["flat/recommended"],
prettier, prettier,
...svelte.configs.prettier, ...svelte.configs["flat/prettier"],
{ {
languageOptions: { languageOptions: {
globals: { globals: {
@@ -26,18 +23,13 @@ export default defineConfig(
...globals.node, ...globals.node,
}, },
}, },
rules: {
"no-undef": "off",
},
}, },
{ {
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], files: ["**/*.svelte"],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
parser: ts.parser, parser: ts.parser,
svelteConfig,
}, },
}, },
}, },

View File

@@ -1,18 +0,0 @@
import { defineConfig } from "kysely-ctl";
import { Pool } from "pg";
export default defineConfig({
dialect: "pg",
dialectConfig: {
pool: new Pool({
host: process.env.DATABASE_HOST,
port: process.env.DATABASE_PORT ? parseInt(process.env.DATABASE_PORT) : undefined,
user: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
}),
},
migrations: {
migrationFolder: "./src/lib/server/db/migrations",
},
});

View File

@@ -1,7 +1,7 @@
{ {
"name": "arkvault", "name": "arkvault",
"private": true, "private": true,
"version": "0.6.0", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -11,62 +11,54 @@
"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 .",
"db:up": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d", "db:push": "drizzle-kit push",
"db:down": "docker compose -f docker-compose.dev.yaml -p arkvault-dev down", "db:generate": "drizzle-kit generate",
"db:migrate": "kysely migrate" "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.0", "@eslint/compat": "^1.2.4",
"@iconify-json/material-symbols": "^1.2.50", "@iconify-json/material-symbols": "^1.2.12",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.49.2", "@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^4.0.4",
"@tanstack/svelte-virtual": "^3.13.13", "@types/better-sqlite3": "^7.6.12",
"@trpc/client": "^11.8.1",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34", "@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.8", "@types/node-schedule": "^2.1.7",
"@types/pg": "^8.16.0", "autoprefixer": "^10.4.20",
"autoprefixer": "^10.4.23", "dexie": "^4.0.10",
"axios": "^1.13.2", "drizzle-kit": "^0.22.8",
"dexie": "^4.2.1", "eslint": "^9.17.0",
"eslint": "^9.39.2", "eslint-config-prettier": "^9.1.0",
"eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^2.46.1",
"eslint-plugin-svelte": "^3.13.1", "eslint-plugin-tailwindcss": "^3.17.5",
"eslint-plugin-tailwindcss": "^3.18.2",
"exifreader": "^4.33.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"globals": "^16.5.0", "globals": "^15.14.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"kysely-ctl": "^0.19.0", "mime": "^4.0.6",
"lru-cache": "^11.2.4", "prettier": "^3.4.2",
"mime": "^4.1.0", "prettier-plugin-svelte": "^3.3.2",
"p-limit": "^7.2.0", "prettier-plugin-tailwindcss": "^0.6.9",
"prettier": "^3.7.4", "svelte": "^5.17.1",
"prettier-plugin-svelte": "^3.4.1", "svelte-check": "^4.1.3",
"prettier-plugin-tailwindcss": "^0.7.2", "tailwindcss": "^3.4.17",
"svelte": "^5.46.1", "typescript": "^5.7.3",
"svelte-check": "^4.3.5", "typescript-eslint": "^8.19.1",
"tailwindcss": "^3.4.19", "unplugin-icons": "^0.22.0",
"typescript": "^5.9.3", "vite": "^5.4.11"
"typescript-eslint": "^8.50.1",
"unplugin-icons": "^22.5.0",
"vite": "^7.3.0"
}, },
"dependencies": { "dependencies": {
"@fastify/busboy": "^3.2.0", "argon2": "^0.41.1",
"@trpc/server": "^11.8.1", "better-sqlite3": "^11.7.2",
"argon2": "^0.44.0", "drizzle-orm": "^0.33.0",
"kysely": "^0.28.9",
"ms": "^2.1.3", "ms": "^2.1.3",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"pg": "^8.16.3", "uuid": "^11.0.4",
"superjson": "^2.2.6", "zod": "^3.24.1"
"uuid": "^13.0.0",
"zod": "^4.2.1"
}, },
"engines": { "engines": {
"node": "^22.0.0", "node": "^22.0.0",
"pnpm": "^10.0.0" "pnpm": "^9.0.0"
} }
} }

3733
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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" />

View File

@@ -1,18 +1,7 @@
import type { ClientInit } from "@sveltejs/kit"; import type { ClientInit } from "@sveltejs/kit";
import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB"; import { getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
import { prepareFileCache } from "$lib/modules/file";
import { prepareOpfs } from "$lib/modules/opfs";
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores"; import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
const requestPersistentStorage = async () => {
const isPersistent = await navigator.storage.persist();
if (isPersistent) {
console.log("[ArkVault] Persistent storage granted.");
} else {
console.warn("[ArkVault] Persistent storage not granted.");
}
};
const prepareClientKeyStore = async () => { const prepareClientKeyStore = async () => {
const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([ const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([
getClientKey("encrypt"), getClientKey("encrypt"),
@@ -40,14 +29,5 @@ const prepareHmacSecretStore = async () => {
}; };
export const init: ClientInit = async () => { export const init: ClientInit = async () => {
await Promise.all([ await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore(), prepareHmacSecretStore()]);
requestPersistentStorage(),
prepareFileCache(),
prepareClientKeyStore(),
prepareMasterKeyStore(),
prepareHmacSecretStore(),
prepareOpfs(),
]);
cleanupDanglingInfos(); // Intended
}; };

View File

@@ -2,15 +2,15 @@ import type { ServerInit } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks"; import { sequence } from "@sveltejs/kit/hooks";
import schedule from "node-schedule"; import schedule from "node-schedule";
import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client"; import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
import { migrateDB } from "$lib/server/db/kysely"; import { migrateDB } from "$lib/server/db/drizzle";
import { import {
cleanupExpiredSessions, cleanupExpiredSessions,
cleanupExpiredSessionUpgradeChallenges, cleanupExpiredSessionUpgradeChallenges,
} from "$lib/server/db/session"; } from "$lib/server/db/session";
import { authenticate, setAgentInfo } from "$lib/server/middlewares"; import { authenticate, setAgentInfo } from "$lib/server/middlewares";
export const init: ServerInit = async () => { export const init: ServerInit = () => {
await migrateDB(); migrateDB();
schedule.scheduleJob("0 * * * *", () => { schedule.scheduleJob("0 * * * *", () => {
cleanupExpiredUserClientChallenges(); cleanupExpiredUserClientChallenges();

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}

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}

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>

View File

@@ -1,40 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
import { fade, fly } from "svelte/transition";
import { AdaptiveDiv } from "$lib/components/atoms";
interface Props {
children: Snippet;
class?: ClassValue;
isOpen: boolean;
onclose?: () => void;
}
let { children, class: className, isOpen = $bindable(), onclose }: Props = $props();
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={onclose || (() => (isOpen = false))}
class="fixed inset-0 z-10 flex items-end justify-center"
>
<div
class="absolute inset-0 bg-black bg-opacity-50"
transition:fade|global={{ duration: 100 }}
></div>
<AdaptiveDiv class="z-10 w-full">
<div
onclick={(e) => e.stopPropagation()}
class="flex max-h-[70vh] min-h-[30vh] flex-col rounded-t-2xl bg-white"
transition:fly|global={{ y: 100, duration: 200 }}
>
<div class={["flex-grow overflow-y-auto", className]}>
{@render children()}
</div>
</div>
</AdaptiveDiv>
</div>
{/if}

View File

@@ -1,31 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
import { fade } from "svelte/transition";
import { AdaptiveDiv } from "$lib/components/atoms";
interface Props {
children: Snippet;
class?: ClassValue;
isOpen: boolean;
onclose?: () => void;
}
let { children, class: className, isOpen = $bindable(), onclose }: Props = $props();
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={onclose || (() => (isOpen = false))}
class="fixed inset-0 z-10 bg-black bg-opacity-50"
transition:fade|global={{ duration: 100 }}
>
<AdaptiveDiv class="flex h-full items-center justify-center px-4">
<div onclick={(e) => e.stopPropagation()} class={["rounded-2xl bg-white p-4", className]}>
{@render children()}
</div>
</AdaptiveDiv>
</div>
{/if}

View File

@@ -1,46 +0,0 @@
<script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import { untrack, type Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
class?: ClassValue;
count: number;
item: Snippet<[index: number]>;
itemHeight: (index: number) => number;
placeholder?: Snippet;
}
let { class: className, count, item, itemHeight, placeholder }: Props = $props();
const virtualizer = $derived(
createWindowVirtualizer({
count: untrack(() => count),
estimateSize: itemHeight,
}),
);
const measureItem = (node: HTMLElement) => {
$effect(() => $virtualizer.measureElement(node));
};
$effect(() => $virtualizer.setOptions({ count }));
</script>
<div class={["relative", className]}>
<div style:height="{$virtualizer.getTotalSize()}px">
{#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)}
<div
class="absolute left-0 top-0 w-full"
style:transform="translateY({virtualItem.start}px)"
data-index={virtualItem.index}
use:measureItem
>
{@render item(virtualItem.index)}
</div>
{/each}
</div>
{#if placeholder && $virtualizer.getVirtualItems().length === 0}
{@render placeholder()}
{/if}
</div>

View File

@@ -1,59 +0,0 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
interface Props {
actionButtonClass?: ClassValue;
actionButtonIcon?: Component<SvelteHTMLElements["svg"]>;
children: Snippet;
class?: ClassValue;
onActionButtonClick?: () => void;
onclick?: () => void;
}
let {
actionButtonIcon: ActionButtonIcon,
actionButtonClass: actionButtonClassName,
children,
class: className,
onActionButtonClick,
onclick,
}: Props = $props();
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
id="container"
onclick={onclick && (() => setTimeout(onclick, 100))}
class={["rounded-xl", className]}
>
<div id="children" class="flex h-full items-center gap-x-4 p-2 transition">
<div class="flex-grow overflow-x-hidden">
{@render children()}
</div>
{#if ActionButtonIcon}
<button
id="action-button"
onclick={(e) => {
e.stopPropagation();
if (onActionButtonClick) {
setTimeout(onActionButtonClick, 100);
}
}}
class={["flex-shrink-0 rounded-full p-1 text-lg active:bg-gray-100", actionButtonClassName]}
>
<ActionButtonIcon />
</button>
{/if}
</div>
</div>
<style>
#container:active:not(:has(#action-button:active)) {
@apply bg-gray-100;
}
#children:active:not(:has(#action-button:active)) {
@apply scale-95;
}
</style>

View File

@@ -1,38 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
children: Snippet;
class?: ClassValue;
color?: "primary" | "gray";
onclick?: () => void;
}
let { children, class: className, color = "primary", onclick }: Props = $props();
let bgColor = $derived(
{
primary: "bg-primary-600 active:bg-primary-500",
gray: "bg-gray-300 active:bg-gray-400",
}[color],
);
let textColor = $derived(
{
primary: "text-white",
gray: "text-gray-800",
}[color],
);
</script>
<button
onclick={onclick && (() => setTimeout(onclick, 100))}
class={[
"h-12 min-w-fit rounded-xl p-3 font-medium transition active:scale-95",
bgColor,
textColor,
className,
]}
>
{@render children()}
</button>

View File

@@ -1,26 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
import IconChevronRight from "~icons/material-symbols/chevron-right";
interface Props {
children: Snippet;
class?: ClassValue;
onclick?: () => void;
}
let { children, class: className, onclick }: Props = $props();
</script>
<button
onclick={onclick && (() => setTimeout(onclick, 100))}
class={["rounded-xl active:bg-gray-100", className]}
>
<div class="flex h-full items-center gap-x-4 p-2 transition active:scale-95">
<div class="flex-grow">
{@render children()}
</div>
<IconChevronRight class="flex-shrink-0 text-xl text-gray-800" />
</div>
</button>

View File

@@ -1,42 +0,0 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file";
interface Props {
info: Writable<FileInfo | null>;
onclick?: (file: FileInfo) => void;
}
let { info, onclick }: Props = $props();
let thumbnail: string | undefined = $state();
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script>
{#if $info}
<button
onclick={() => onclick?.($info)}
class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
>
{#if thumbnail}
<img src={thumbnail} alt={$info.name} class="h-full w-full object-cover" />
{:else}
<div class="h-full w-full bg-gray-100"></div>
{/if}
</button>
{/if}

View File

@@ -1,27 +0,0 @@
<script lang="ts">
import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { AdaptiveDiv } from "$lib/components/atoms";
interface Props {
class: ClassValue;
icon: Component<SvelteHTMLElements["svg"]>;
onclick?: () => void;
}
let { class: className, icon: Icon, onclick }: Props = $props();
</script>
<div class="pointer-events-none fixed inset-0">
<AdaptiveDiv class="relative h-full">
<button
onclick={onclick && (() => setTimeout(onclick, 100))}
class={[
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full bg-gray-300 text-xl shadow-lg transition active:scale-95 active:bg-gray-400",
className,
]}
>
<Icon />
</button>
</AdaptiveDiv>
</div>

View File

@@ -1,15 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
children: Snippet;
class?: ClassValue;
}
let { children, class: className }: Props = $props();
</script>
<div class={["mx-auto max-w-screen-md", className]}>
{@render children()}
</div>

View File

@@ -1,15 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
children: Snippet;
class?: ClassValue;
}
let { children, class: className }: Props = $props();
</script>
<div class={["sticky bottom-0 bg-white pb-4", className]}>
{@render children()}
</div>

View File

@@ -1,15 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
children: Snippet;
class?: ClassValue;
}
let { children, class: className }: Props = $props();
</script>
<div class={["flex flex-grow flex-col justify-between px-4", className]}>
{@render children()}
</div>

View File

@@ -1,6 +0,0 @@
export { default as BottomSheet } from "./BottomSheet.svelte";
export * from "./buttons";
export * from "./divs";
export * from "./inputs";
export { default as Modal } from "./Modal.svelte";
export { default as RowVirtualizer } from "./RowVirtualizer.svelte";

View File

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

View File

@@ -1,40 +0,0 @@
<script lang="ts">
import type { ClassValue } from "svelte/elements";
interface Props {
class?: ClassValue;
placeholder: string;
type?: "text" | "password";
value?: string;
}
let { class: className, placeholder, type = "text", value = $bindable("") }: Props = $props();
</script>
<div class={className}>
<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="pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 transform text-xl text-gray-400 transition-all duration-300 ease-in-out"
>
{placeholder}
</label>
</div>
</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>

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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -10,10 +10,14 @@
</script> </script>
<button <button
onclick={onclick && (() => setTimeout(onclick, 100))} 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>

View File

@@ -1,6 +1,4 @@
export { default as ActionEntryButton } from "./ActionEntryButton.svelte";
export { default as Button } from "./Button.svelte"; export { default as Button } from "./Button.svelte";
export { default as EntryButton } from "./EntryButton.svelte"; export { default as EntryButton } from "./EntryButton.svelte";
export { default as FileThumbnailButton } from "./FileThumbnailButton.svelte";
export { default as FloatingButton } from "./FloatingButton.svelte"; export { default as FloatingButton } from "./FloatingButton.svelte";
export { default as TextButton } from "./TextButton.svelte"; export { default as TextButton } from "./TextButton.svelte";

View File

@@ -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>

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>

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>

View File

@@ -1,3 +1,3 @@
export { default as AdaptiveDiv } from "./AdaptiveDiv.svelte"; export { default as AdaptiveDiv } from "./AdaptiveDiv.svelte";
export { default as BottomDiv } from "./BottomDiv.svelte"; export { default as BottomDiv } from "./BottomDiv.svelte";
export { default as FullscreenDiv } from "./FullscreenDiv.svelte"; export { default as TitleDiv } from "./TitleDiv.svelte";

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";

View File

@@ -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>

View File

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

View File

@@ -1,57 +0,0 @@
<script module lang="ts">
export type ConfirmHandler = () => void | Promise<void> | boolean | Promise<boolean>;
</script>
<script lang="ts">
import type { Snippet } from "svelte";
import { Button, Modal } from "$lib/components/atoms";
interface Props {
cancelText?: string;
children: Snippet;
confirmText: string;
isOpen: boolean;
onbeforeclose?: () => void;
oncancel?: () => void;
onConfirmClick: ConfirmHandler;
title: string;
}
let {
cancelText = "닫기",
children,
confirmText,
isOpen = $bindable(),
onbeforeclose,
oncancel,
onConfirmClick,
title,
}: Props = $props();
const closeModal = () => {
onbeforeclose?.();
isOpen = false;
};
const cancelAction = () => {
oncancel?.();
closeModal();
};
const confirmAction = async () => {
if ((await onConfirmClick()) !== false) {
closeModal();
}
};
</script>
<Modal bind:isOpen onclose={cancelAction} class="space-y-4">
<div class="flex flex-col gap-y-2 break-keep">
<p class="text-xl font-bold">{title}</p>
{@render children()}
</div>
<div class="flex gap-x-2">
<Button color="gray" onclick={cancelAction} class="flex-1">{cancelText}</Button>
<Button onclick={confirmAction} class="flex-1">{confirmText}</Button>
</div>
</Modal>

View File

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

View File

@@ -1,43 +0,0 @@
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { CategoryLabel } from "$lib/components/molecules";
import type { CategoryInfo } from "$lib/modules/filesystem";
import type { SelectedCategory } from "./service";
interface Props {
info: Writable<CategoryInfo | null>;
menuIcon?: Component<SvelteHTMLElements["svg"]>;
onclick: (category: SelectedCategory) => void;
onMenuClick?: (category: SelectedCategory) => void;
}
let { info, menuIcon, onclick, onMenuClick }: Props = $props();
const openCategory = () => {
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ id, dataKey, dataKeyVersion, name });
};
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onMenuClick!({ id, dataKey, dataKeyVersion, name });
};
</script>
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openCategory}
actionButtonIcon={menuIcon}
onActionButtonClick={openMenu}
>
<CategoryLabel name={$info.name!} />
</ActionEntryButton>
{/if}

View File

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

View File

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

View File

@@ -1,30 +0,0 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { EntryButton } from "$lib/components/atoms";
import { IconLabel } from "$lib/components/molecules";
interface Props {
children: Snippet;
class?: ClassValue;
icon: Component<SvelteHTMLElements["svg"]>;
iconClass?: ClassValue;
onclick?: () => void;
textClass?: ClassValue;
}
let {
children,
class: className,
icon,
iconClass: iconClassName,
onclick,
textClass: textClassName,
}: Props = $props();
</script>
<EntryButton {onclick} class={className}>
<IconLabel {icon} class="h-full" iconClass={iconClassName} textClass={textClassName}>
{@render children()}
</IconLabel>
</EntryButton>

View File

@@ -1,67 +0,0 @@
<script lang="ts">
import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import IconAddCircle from "~icons/material-symbols/add-circle";
interface Props {
class?: ClassValue;
info: CategoryInfo;
onSubCategoryClick: (subCategory: SelectedCategory) => void;
onSubCategoryCreateClick: () => void;
onSubCategoryMenuClick?: (category: SelectedCategory) => void;
subCategoryCreatePosition?: "top" | "bottom";
subCategoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
}
let {
class: className,
info,
onSubCategoryClick,
onSubCategoryCreateClick,
onSubCategoryMenuClick,
subCategoryCreatePosition = "bottom",
subCategoryMenuIcon,
}: Props = $props();
let subCategories: Writable<CategoryInfo | null>[] = $state([]);
$effect(() => {
subCategories = info.subCategoryIds.map((id) =>
getCategoryInfo(id, $masterKeyStore?.get(1)?.key!),
);
});
</script>
<div class={["space-y-1", 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}
{#key info}
<Categories
categories={subCategories}
categoryMenuIcon={subCategoryMenuIcon}
onCategoryClick={onSubCategoryClick}
onCategoryMenuClick={onSubCategoryMenuClick}
/>
{/key}
{#if subCategoryCreatePosition === "bottom"}
{@render subCategoryCreate()}
{/if}
</div>

View File

@@ -1,43 +0,0 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { TitleLabel } from "$lib/components/molecules";
interface Props {
children?: Snippet;
childrenClass?: ClassValue;
class?: ClassValue;
description?: Snippet;
icon?: Component<SvelteHTMLElements["svg"]>;
title: Snippet;
titleClass?: ClassValue;
}
let {
children,
childrenClass: childrenClassName,
class: className,
description,
icon,
title,
titleClass: titleClassName,
}: Props = $props();
</script>
<div class={["space-y-4 py-4", className]}>
<div class="space-y-2 break-keep">
<TitleLabel {icon} textClass={titleClassName}>
{@render title()}
</TitleLabel>
{#if description}
<p>
{@render description()}
</p>
{/if}
</div>
{#if children}
<div class={childrenClassName}>
{@render children()}
</div>
{/if}
</div>

View File

@@ -1,37 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
import IconArrowBack from "~icons/material-symbols/arrow-back";
interface Props {
children?: Snippet;
class?: ClassValue;
onBackClick?: () => void;
title?: string;
}
let { children, class: className, onBackClick, title }: Props = $props();
</script>
<div
class={[
"sticky top-0 z-10 flex items-center justify-between gap-x-2 px-2 py-3 backdrop-blur-2xl",
className,
]}
>
<button
onclick={onBackClick || (() => history.back())}
class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-black active:bg-opacity-[0.04]"
>
<IconArrowBack class="text-2xl" />
</button>
{#if title}
<p class="flex-grow truncate text-center text-lg font-semibold">{title}</p>
{/if}
<div class="w-[2.3rem] flex-shrink-0">
{#if children}
{@render children()}
{/if}
</div>
</div>

View File

@@ -1,9 +0,0 @@
export * from "./ActionModal.svelte";
export { default as ActionModal } from "./ActionModal.svelte";
export * from "./Categories";
export { default as Categories } from "./Categories";
export { default as IconEntryButton } from "./IconEntryButton.svelte";
export * from "./labels";
export { default as SubCategories } from "./SubCategories.svelte";
export { default as TitledDiv } from "./TitledDiv.svelte";
export { default as TopBar } from "./TopBar.svelte";

View File

@@ -1,28 +0,0 @@
<script lang="ts">
import type { ClassValue } from "svelte/elements";
import { IconLabel } from "$lib/components/molecules";
import IconCategory from "~icons/material-symbols/category";
interface Props {
class?: ClassValue;
name: string;
subtext?: string;
textClass?: ClassValue;
}
let { class: className, name, subtext, textClass: textClassName }: Props = $props();
</script>
{#snippet subtextSnippet()}
{subtext}
{/snippet}
<IconLabel
icon={IconCategory}
subtext={subtext ? subtextSnippet : undefined}
class={className}
textClass={textClassName}
>
{name}
</IconLabel>

View File

@@ -1,53 +0,0 @@
<script lang="ts">
import type { ClassValue } from "svelte/elements";
import { IconLabel } from "$lib/components/molecules";
import IconFolder from "~icons/material-symbols/folder";
import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload";
import IconDraft from "~icons/material-symbols/draft";
interface Props {
class?: ClassValue;
name: string;
subtext?: string;
textClass?: ClassValue;
thumbnail?: string;
type: "directory" | "parent-directory" | "file";
}
let {
class: className,
name,
subtext,
textClass: textClassName,
thumbnail,
type,
}: Props = $props();
</script>
{#snippet iconSnippet()}
<div class="flex h-10 w-10 items-center justify-center text-xl">
{#if thumbnail}
<img src={thumbnail} alt={name} loading="lazy" class="aspect-square rounded object-cover" />
{:else if type === "directory"}
<IconFolder />
{:else if type === "parent-directory"}
<IconDriveFolderUpload class="text-yellow-500" />
{:else}
<IconDraft class="text-blue-400" />
{/if}
</div>
{/snippet}
{#snippet subtextSnippet()}
{subtext}
{/snippet}
<IconLabel
{iconSnippet}
subtext={subtext ? subtextSnippet : undefined}
class={className}
textClass={textClassName}
>
{name}
</IconLabel>

View File

@@ -1,46 +0,0 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
interface Props {
children: Snippet;
class?: ClassValue;
icon?: Component<SvelteHTMLElements["svg"]>;
iconClass?: ClassValue;
iconSnippet?: Snippet;
subtext?: Snippet;
textClass?: ClassValue;
}
let {
children,
class: className,
icon: Icon,
iconClass: iconClassName,
iconSnippet,
subtext,
textClass: textClassName,
}: Props = $props();
</script>
<div class={["flex items-center gap-x-4", className]}>
{#if iconSnippet}
<div class={["flex-shrink-0", iconClassName]}>
{@render iconSnippet()}
</div>
{:else if Icon}
<div class={["flex-shrink-0 text-lg", iconClassName]}>
<Icon />
</div>
{/if}
<div class="flex flex-grow flex-col overflow-x-hidden text-left">
<p class={["truncate font-medium", textClassName]}>
{@render children()}
</p>
{#if subtext}
<p class="truncate text-xs text-gray-800">
{@render subtext()}
</p>
{/if}
</div>
</div>

View File

@@ -1,24 +0,0 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
interface Props {
children: Snippet;
class?: ClassValue;
icon?: Component<SvelteHTMLElements["svg"]>;
textClass?: ClassValue;
}
let { children, class: className, icon: Icon, textClass: textClassName }: Props = $props();
</script>
<div class={className}>
<div class="flex min-h-[10vh] items-center">
{#if Icon}
<Icon class="text-5xl text-gray-600" />
{/if}
</div>
<p class={["text-3xl font-bold", textClassName]}>
{@render children()}
</p>
</div>

View File

@@ -1,4 +0,0 @@
export { default as CategoryLabel } from "./CategoryLabel.svelte";
export { default as DirectoryEntryLabel } from "./DirectoryEntryLabel.svelte";
export { default as IconLabel } from "./IconLabel.svelte";
export { default as TitleLabel } from "./TitleLabel.svelte";

View File

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

View File

@@ -1,59 +0,0 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload, type SelectedFile } from "./service";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: Writable<FileInfo | null>;
onclick: (selectedFile: SelectedFile) => void;
onRemoveClick?: (selectedFile: SelectedFile) => void;
}
let { info, onclick, onRemoveClick }: Props = $props();
let thumbnail: string | undefined = $state();
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ id, dataKey, dataKeyVersion, name });
};
const removeFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script>
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openFile}
actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={removeFile}
>
<DirectoryEntryLabel type="file" {thumbnail} name={$info.name} />
</ActionEntryButton>
{/if}

View File

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

View File

@@ -1,8 +0,0 @@
export { requestFileThumbnailDownload } from "$lib/services/file";
export interface SelectedFile {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string;
}

View File

@@ -1,134 +0,0 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { FileThumbnailButton, RowVirtualizer } from "$lib/components/atoms";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
interface Props {
files: Writable<FileInfo | null>[];
onFileClick?: (file: FileInfo) => void;
}
let { files, onFileClick }: Props = $props();
type FileEntry =
| { date?: undefined; contentType?: undefined; info: Writable<FileInfo | null> }
| { date: Date; contentType: string; info: Writable<FileInfo | null> };
type Row =
| { type: "header"; label: string }
| { type: "items"; items: FileEntry[]; isLast: boolean };
let filesWithDate: FileEntry[] = $state([]);
let rows: Row[] = $state([]);
$effect(() => {
filesWithDate = files.map((file) => {
const info = get(file);
if (info) {
return {
date: info.createdAt ?? info.lastModifiedAt,
contentType: info.contentType,
info: file,
};
} else {
return { info: file };
}
});
const buildRows = () => {
const map = new Map<string, FileEntry[]>();
for (const file of filesWithDate) {
if (
!file.date ||
!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))
) {
continue;
}
const date = formatDateSortable(file.date);
const entries = map.get(date) ?? [];
entries.push(file);
map.set(date, entries);
}
const newRows: Row[] = [];
const sortedDates = Array.from(map.keys()).sort((a, b) => b.localeCompare(a));
for (const date of sortedDates) {
const entries = map.get(date)!;
sortEntries(entries, SortBy.DATE_DESC);
newRows.push({
type: "header",
label: formatDate(entries[0]!.date!),
});
for (let i = 0; i < entries.length; i += 4) {
newRows.push({
type: "items",
items: entries.slice(i, i + 4),
isLast: i + 4 >= entries.length,
});
}
}
rows = newRows;
};
return untrack(() => {
buildRows();
const unsubscribes = filesWithDate.map((file) =>
file.info.subscribe((value) => {
const newDate = value?.createdAt ?? value?.lastModifiedAt;
const newContentType = value?.contentType;
if (file.date?.getTime() === newDate?.getTime() && file.contentType === newContentType) {
return;
}
file.date = newDate;
file.contentType = newContentType;
buildRows();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
<RowVirtualizer
count={rows.length}
itemHeight={(index) =>
rows[index]!.type === "header"
? 28
: Math.ceil(rows[index]!.items.length / 4) * 181 +
(Math.ceil(rows[index]!.items.length / 4) - 1) * 4 +
16}
class="flex flex-grow flex-col"
>
{#snippet item(index)}
{@const row = rows[index]!}
{#if row.type === "header"}
<p class="pb-2 text-sm font-medium">{row.label}</p>
{:else}
<div class={["grid grid-cols-4 gap-x-1", row.isLast ? "pb-4" : "pb-1"]}>
{#each row.items as { info }}
<FileThumbnailButton {info} onclick={onFileClick} />
{/each}
</div>
{/if}
{/snippet}
{#snippet placeholder()}
<div class="flex h-full flex-grow items-center justify-center">
<p class="text-gray-500">
{#if files.length === 0}
업로드된 파일이 없어요.
{:else if filesWithDate.length === 0}
파일 목록을 불러오고 있어요.
{:else}
사진 또는 동영상이 없어요.
{/if}
</p>
</div>
{/snippet}
</RowVirtualizer>

View File

@@ -1,4 +0,0 @@
export * from "./Category";
export { default as Category } from "./Category";
export { default as Gallery } from "./Gallery.svelte";
export * from "./modals";

View File

@@ -1,18 +0,0 @@
<script lang="ts">
import { TextInputModal } from "$lib/components/organisms";
interface Props {
isOpen: boolean;
onCreateClick: (name: string) => Promise<boolean>;
}
let { isOpen = $bindable(), onCreateClick }: Props = $props();
</script>
<TextInputModal
bind:isOpen
title="새 카테고리"
placeholder="카테고리 이름"
submitText="만들기"
onSubmitClick={onCreateClick}
/>

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import { ActionModal } from "$lib/components/molecules";
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>

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import { TextInputModal } from "$lib/components/organisms";
interface Props {
isOpen: boolean;
onbeforeclose?: () => void;
onRenameClick: (newName: string) => Promise<boolean>;
originalName: string | undefined;
}
let { isOpen = $bindable(), onbeforeclose, onRenameClick, originalName }: Props = $props();
</script>
<TextInputModal
bind:isOpen
{onbeforeclose}
title="이름 바꾸기"
placeholder="이름"
defaultValue={originalName}
submitText="바꾸기"
onSubmitClick={onRenameClick}
/>

View File

@@ -1,42 +0,0 @@
<script lang="ts">
import { TextInput } from "$lib/components/atoms";
import { ActionModal, type ConfirmHandler } from "$lib/components/molecules";
interface Props {
defaultValue?: string;
isOpen: boolean;
onbeforeclose?: () => void;
onSubmitClick: (value: string) => ReturnType<ConfirmHandler>;
placeholder: string;
submitText: string;
title: string;
}
let {
defaultValue = "",
isOpen = $bindable(),
onbeforeclose,
onSubmitClick,
placeholder,
submitText,
title,
}: Props = $props();
let value = $state("");
$effect.pre(() => {
if (isOpen) {
value = defaultValue;
}
});
</script>
<ActionModal
bind:isOpen
{onbeforeclose}
{title}
confirmText={submitText}
onConfirmClick={() => onSubmitClick(value)}
>
<TextInput bind:value {placeholder} class="mb-3" />
</ActionModal>

View File

@@ -1,4 +0,0 @@
export { default as CategoryCreateModal } from "./CategoryCreateModal.svelte";
export { default as ForceLoginModal } from "./ForceLoginModal.svelte";
export { default as RenameModal } from "./RenameModal.svelte";
export { default as TextInputModal } from "./TextInputModal.svelte";

11
src/lib/hooks/callApi.ts Normal file
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,
});
};

2
src/lib/hooks/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./callApi";
export * from "./gotoStateful";

View File

@@ -1,28 +0,0 @@
import { Dexie, type EntityTable } from "dexie";
export interface FileCacheIndex {
fileId: number;
cachedAt: Date;
lastRetrievedAt: Date;
size: number;
}
const cacheIndex = new Dexie("cacheIndex") as Dexie & {
fileCache: EntityTable<FileCacheIndex, "fileId">;
};
cacheIndex.version(1).stores({
fileCache: "fileId",
});
export const getFileCacheIndex = async () => {
return await cacheIndex.fileCache.toArray();
};
export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => {
await cacheIndex.fileCache.put(fileCacheIndex);
};
export const deleteFileCacheIndex = async (fileId: number) => {
await cacheIndex.fileCache.delete(fileId);
};

View File

@@ -1,148 +0,0 @@
import { Dexie, type EntityTable } from "dexie";
export type DirectoryId = "root" | number;
interface DirectoryInfo {
id: number;
parentId: DirectoryId;
name: string;
}
interface FileInfo {
id: number;
parentId: DirectoryId;
name: string;
contentType: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
export type CategoryId = "root" | number;
interface CategoryInfo {
id: number;
parentId: CategoryId;
name: string;
files: { id: number; isRecursive: boolean }[];
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;
});
});
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.put(directoryInfo);
};
export const deleteDirectoryInfo = async (id: number) => {
await filesystem.directory.delete(id);
};
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 storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.put(fileInfo);
};
export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id);
};
export const getCategoryInfos = async (parentId: CategoryId) => {
return await filesystem.category.where({ parentId }).toArray();
};
export const getCategoryInfo = async (id: number) => {
return await filesystem.category.get(id);
};
export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.put(categoryInfo);
};
export const 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 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(),
]);
};

View File

@@ -1,3 +0,0 @@
export * from "./cacheIndex";
export * from "./filesystem";
export * from "./keyStore";

View File

@@ -46,56 +46,6 @@ 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 window.crypto.subtle.importKey(
"spki",
decodeFromBase64(encryptKeyBase64),
algorithm,
true,
["encrypt", "wrapKey"],
);
const decryptKey = await window.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 window.crypto.subtle.importKey(
"pkcs8",
decodeFromBase64(signKeyBase64),
algorithm,
true,
["sign"],
);
const verifyKey = await window.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 window.crypto.subtle.importKey( return await window.crypto.subtle.importKey(

89
src/lib/modules/file.ts Normal file
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;
};

View File

@@ -1,80 +0,0 @@
import { LRUCache } from "lru-cache";
import {
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
storeFileCacheIndex,
deleteFileCacheIndex,
type FileCacheIndex,
} from "$lib/indexedDB";
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
const fileCacheIndex = new Map<number, FileCacheIndex>();
const loadedThumbnails = new LRUCache<number, string>({ max: 100 });
export const prepareFileCache = async () => {
for (const cache of await getFileCacheIndexFromIndexedDB()) {
fileCacheIndex.set(cache.fileId, cache);
}
};
export const getFileCacheIndex = () => {
return Array.from(fileCacheIndex.values());
};
export const getFileCache = async (fileId: number) => {
const cacheIndex = fileCacheIndex.get(fileId);
if (!cacheIndex) return null;
cacheIndex.lastRetrievedAt = new Date();
storeFileCacheIndex(cacheIndex); // Intended
return await readFile(`/cache/${fileId}`);
};
export const storeFileCache = async (fileId: number, fileBuffer: ArrayBuffer) => {
const now = new Date();
await writeFile(`/cache/${fileId}`, fileBuffer);
const cacheIndex: FileCacheIndex = {
fileId,
cachedAt: now,
lastRetrievedAt: now,
size: fileBuffer.byteLength,
};
fileCacheIndex.set(fileId, cacheIndex);
await storeFileCacheIndex(cacheIndex);
};
export const deleteFileCache = async (fileId: number) => {
if (!fileCacheIndex.has(fileId)) return;
fileCacheIndex.delete(fileId);
await deleteFile(`/cache/${fileId}`);
await deleteFileCacheIndex(fileId);
};
export const getFileThumbnailCache = async (fileId: number) => {
const thumbnail = loadedThumbnails.get(fileId);
if (thumbnail) return thumbnail;
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
if (!thumbnailBuffer) return null;
const thumbnailUrl = getThumbnailUrl(thumbnailBuffer);
loadedThumbnails.set(fileId, thumbnailUrl);
return thumbnailUrl;
};
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer));
};
export const deleteFileThumbnailCache = async (fileId: number) => {
loadedThumbnails.delete(fileId);
await deleteFile(`/thumbnail/file/${fileId}`);
};
export const deleteAllFileThumbnailCaches = async () => {
loadedThumbnails.clear();
await deleteDirectory("/thumbnail/file");
};

View File

@@ -1,84 +0,0 @@
import axios from "axios";
import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import { decryptData } from "$lib/modules/crypto";
import { fileDownloadStatusStore, type FileDownloadStatus } from "$lib/stores";
const requestFileDownload = limitFunction(
async (status: Writable<FileDownloadStatus>, id: number) => {
status.update((value) => {
value.status = "downloading";
return value;
});
const res = await axios.get(`/api/file/${id}/download`, {
responseType: "arraybuffer",
onDownloadProgress: ({ progress, rate, estimated }) => {
status.update((value) => {
value.progress = progress;
value.rate = rate;
value.estimated = estimated;
return value;
});
},
});
const fileEncrypted: ArrayBuffer = res.data;
status.update((value) => {
value.status = "decryption-pending";
return value;
});
return fileEncrypted;
},
{ concurrency: 1 },
);
const decryptFile = limitFunction(
async (
status: Writable<FileDownloadStatus>,
fileEncrypted: ArrayBuffer,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
status.update((value) => {
value.status = "decrypting";
return value;
});
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
status.update((value) => {
value.status = "decrypted";
value.result = fileBuffer;
return value;
});
return fileBuffer;
},
{ concurrency: 4 },
);
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
const status = writable<FileDownloadStatus>({
id,
status: "download-pending",
});
fileDownloadStatusStore.update((value) => {
value.push(status);
return value;
});
try {
return await decryptFile(
status,
await requestFileDownload(status, id),
fileEncryptedIv,
dataKey,
);
} catch (e) {
status.update((value) => {
value.status = "error";
return value;
});
throw e;
}
};

View File

@@ -1,3 +0,0 @@
export * from "./cache";
export * from "./download";
export * from "./upload";

View File

@@ -1,264 +0,0 @@
import axios from "axios";
import ExifReader from "exifreader";
import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import {
encodeToBase64,
generateDataKey,
wrapDataKey,
encryptData,
encryptString,
digestMessage,
signMessageHmac,
} from "$lib/modules/crypto";
import { generateThumbnail } from "$lib/modules/thumbnail";
import type {
FileThumbnailUploadRequest,
FileUploadRequest,
FileUploadResponse,
} from "$lib/server/schemas";
import {
fileUploadStatusStore,
type MasterKey,
type HmacSecret,
type FileUploadStatus,
} from "$lib/stores";
import { trpc } from "$trpc/client";
const requestDuplicateFileScan = limitFunction(
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
const fileBuffer = await file.arrayBuffer();
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
const files = await trpc().file.listByHash.query({
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
});
if (files.length === 0 || (await onDuplicate())) {
return { fileBuffer, 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");
};
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);
if (!year || !month || !day || !hour || !minute || !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 encryptFile = limitFunction(
async (
status: Writable<FileUploadStatus>,
file: File,
fileBuffer: ArrayBuffer,
masterKey: MasterKey,
) => {
status.update((value) => {
value.status = "encrypting";
return value;
});
const fileType = getFileType(file);
let createdAt;
if (fileType.startsWith("image/")) {
createdAt = extractExifDateTime(fileBuffer);
}
const { dataKey, dataKeyVersion } = await generateDataKey();
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
const fileEncrypted = await encryptData(fileBuffer, dataKey);
const fileEncryptedHash = encodeToBase64(await digestMessage(fileEncrypted.ciphertext));
const nameEncrypted = await encryptString(file.name, dataKey);
const createdAtEncrypted =
createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
const thumbnail = await generateThumbnail(fileBuffer, fileType);
const thumbnailBuffer = await thumbnail?.arrayBuffer();
const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey));
status.update((value) => {
value.status = "upload-pending";
return value;
});
return {
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail: thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted },
};
},
{ concurrency: 4 },
);
const requestFileUpload = limitFunction(
async (status: Writable<FileUploadStatus>, form: FormData, thumbnailForm: FormData | null) => {
status.update((value) => {
value.status = "uploading";
return value;
});
const res = await axios.post("/api/file/upload", form, {
onUploadProgress: ({ progress, rate, estimated }) => {
status.update((value) => {
value.progress = progress;
value.rate = rate;
value.estimated = estimated;
return value;
});
},
});
const { file }: FileUploadResponse = res.data;
if (thumbnailForm) {
try {
await axios.post(`/api/file/${file}/thumbnail/upload`, thumbnailForm);
} catch (e) {
// TODO
console.error(e);
}
}
status.update((value) => {
value.status = "uploaded";
return value;
});
return { fileId: file };
},
{ concurrency: 1 },
);
export const uploadFile = async (
file: File,
parentId: "root" | number,
hmacSecret: HmacSecret,
masterKey: MasterKey,
onDuplicate: () => Promise<boolean>,
): Promise<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
> => {
const status = writable<FileUploadStatus>({
name: file.name,
parentId,
status: "encryption-pending",
});
fileUploadStatusStore.update((value) => {
value.push(status);
return value;
});
try {
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
file,
hmacSecret,
onDuplicate,
);
if (!fileBuffer || !fileSigned) {
status.update((value) => {
value.status = "canceled";
return value;
});
fileUploadStatusStore.update((value) => {
value = value.filter((v) => v !== status);
return value;
});
return undefined;
}
const {
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail,
} = await encryptFile(status, file, fileBuffer, masterKey);
const form = new FormData();
form.set(
"metadata",
JSON.stringify({
parent: parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion.toISOString(),
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
contentType: fileType,
contentIv: fileEncrypted.iv,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
} satisfies FileUploadRequest),
);
form.set("content", new Blob([fileEncrypted.ciphertext]));
form.set("checksum", fileEncryptedHash);
let thumbnailForm = null;
if (thumbnail) {
thumbnailForm = new FormData();
thumbnailForm.set(
"metadata",
JSON.stringify({
dekVersion: dataKeyVersion.toISOString(),
contentIv: thumbnail.iv,
} satisfies FileThumbnailUploadRequest),
);
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
}
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
} catch (e) {
status.update((value) => {
value.status = "error";
return value;
});
throw e;
}
};

View File

@@ -1,369 +0,0 @@
import { get, writable, type Writable } from "svelte/store";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
type DirectoryId,
type CategoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import { trpc, isTRPCClientError } from "$trpc/client";
export type DirectoryInfo =
| {
id: "root";
parentId?: undefined;
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
parentId: DirectoryId;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
export type CategoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subCategoryIds: number[];
files?: undefined;
isFileRecursive?: undefined;
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subCategoryIds: number[];
files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
};
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const categoryInfoStore = new Map<CategoryId, Writable<CategoryInfo | null>>();
const fetchDirectoryInfoFromIndexedDB = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
) => {
if (get(info)) return;
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
const subDirectoryIds = subDirectories.map(({ id }) => id);
const fileIds = files.map(({ id }) => id);
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
if (!directory) return;
info.set({
id,
parentId: directory.parentId,
name: directory.name,
subDirectoryIds,
fileIds,
});
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
let data;
try {
data = await trpc().directory.get.query({ id });
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteDirectoryInfo(id as number);
return;
}
throw new Error("Failed to fetch directory information");
}
const { metadata, subDirectories: subDirectoryIds, files: fileIds } = data;
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
info.set({
id,
parentId: metadata!.parent,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
});
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
}
};
const fetchDirectoryInfo = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
await fetchDirectoryInfoFromIndexedDB(id, info);
await fetchDirectoryInfoFromServer(id, info, masterKey);
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = directoryInfoStore.get(id);
if (!info) {
info = writable(null);
directoryInfoStore.set(id, info);
}
fetchDirectoryInfo(id, info, masterKey); // Intended
return info;
};
const fetchFileInfoFromIndexedDB = async (id: number, info: Writable<FileInfo | null>) => {
if (get(info)) return;
const file = await getFileInfoFromIndexedDB(id);
if (!file) return;
info.set(file);
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
const fetchFileInfoFromServer = async (
id: number,
info: Writable<FileInfo | null>,
masterKey: CryptoKey,
) => {
let metadata;
try {
metadata = await trpc().file.get.query({ id });
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteFileInfo(id);
return;
}
throw new Error("Failed to fetch file information");
}
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
const createdAt =
metadata.createdAt && metadata.createdAtIv
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
: undefined;
const lastModifiedAt = await decryptDate(
metadata.lastModifiedAt,
metadata.lastModifiedAtIv,
dataKey,
);
info.set({
id,
parentId: metadata.parent,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
};
const fetchFileInfo = async (id: number, info: Writable<FileInfo | null>, masterKey: CryptoKey) => {
await fetchFileInfoFromIndexedDB(id, info);
await fetchFileInfoFromServer(id, info, masterKey);
};
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = fileInfoStore.get(fileId);
if (!info) {
info = writable(null);
fileInfoStore.set(fileId, info);
}
fetchFileInfo(fileId, info, masterKey); // Intended
return info;
};
const fetchCategoryInfoFromIndexedDB = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
) => {
if (get(info)) return;
const [category, subCategories] = await Promise.all([
id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined,
getCategoryInfosFromIndexedDB(id),
]);
const subCategoryIds = subCategories.map(({ id }) => id);
if (id === "root") {
info.set({ id, subCategoryIds });
} else {
if (!category) return;
info.set({
id,
name: category.name,
subCategoryIds,
files: category.files,
isFileRecursive: category.isFileRecursive,
});
}
};
const fetchCategoryInfoFromServer = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
masterKey: CryptoKey,
) => {
let data;
try {
data = await trpc().category.get.query({ id });
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteCategoryInfo(id as number);
return;
}
throw new Error("Failed to fetch category information");
}
const { metadata, subCategories } = data;
if (id === "root") {
info.set({ id, subCategoryIds: subCategories });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
let files;
try {
files = await trpc().category.files.query({ id, recurse: true });
} catch {
throw new Error("Failed to fetch category files");
}
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
let isFileRecursive: boolean | undefined = undefined;
info.update((value) => {
const newValue = {
isFileRecursive: false,
...value,
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subCategoryIds: subCategories,
files: filesMapped,
};
isFileRecursive = newValue.isFileRecursive;
return newValue;
});
await storeCategoryInfo({
id,
parentId: metadata!.parent,
name,
files: filesMapped,
isFileRecursive: isFileRecursive!,
});
}
};
const fetchCategoryInfo = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
masterKey: CryptoKey,
) => {
await fetchCategoryInfoFromIndexedDB(id, info);
await fetchCategoryInfoFromServer(id, info, masterKey);
};
export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = categoryInfoStore.get(categoryId);
if (!info) {
info = writable(null);
categoryInfoStore.set(categoryId, info);
}
fetchCategoryInfo(categoryId, info, masterKey); // Intended
return info;
};
export const updateCategoryInfo = async (
categoryId: number,
changes: { isFileRecursive?: boolean },
) => {
await updateCategoryInfoInIndexedDB(categoryId, changes);
categoryInfoStore.get(categoryId)?.update((value) => {
if (!value) return value;
if (changes.isFileRecursive !== undefined) {
value.isFileRecursive = changes.isFileRecursive;
}
return value;
});
};

View File

@@ -1,65 +0,0 @@
import { z } from "zod";
import { storeClientKey } from "$lib/indexedDB";
import type { ClientKeys } from "$lib/stores";
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;
};
export const storeClientKeys = async (clientKeys: ClientKeys) => {
await Promise.all([
storeClientKey(clientKeys.encryptKey, "encrypt"),
storeClientKey(clientKeys.decryptKey, "decrypt"),
storeClientKey(clientKeys.signKey, "sign"),
storeClientKey(clientKeys.verifyKey, "verify"),
]);
};

View File

@@ -1,97 +0,0 @@
let rootHandle: FileSystemDirectoryHandle | null = null;
export const prepareOpfs = async () => {
rootHandle = await navigator.storage.getDirectory();
};
const getFileHandle = async (path: string, create = true) => {
if (!rootHandle) {
throw new Error("OPFS not prepared");
} else 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 = rootHandle;
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 readFile = async (path: string) => {
const { fileHandle } = await getFileHandle(path, false);
if (!fileHandle) return null;
const file = await fileHandle.getFile();
return await file.arrayBuffer();
};
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();
}
};
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 (!rootHandle) {
throw new Error("OPFS not prepared");
} else 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 = rootHandle;
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 });
};

View File

@@ -1,127 +0,0 @@
import { encodeToBase64 } from "$lib/modules/crypto";
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 generateVideoThumbnail = (videoUrl: string, time = 0) => {
return new Promise<Blob>((resolve, reject) => {
const video = document.createElement("video");
video.onloadedmetadata = () => {
if (video.videoWidth === 0 || video.videoHeight === 0) {
return reject();
}
const callbackId = video.requestVideoFrameCallback(() => {
captureVideoThumbnail(video).then(resolve).catch(reject);
video.cancelVideoFrameCallback(callbackId);
});
video.currentTime = Math.min(time, video.duration);
};
video.onerror = reject;
video.muted = true;
video.playsInline = true;
video.src = videoUrl;
});
};
export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: string) => {
let url;
try {
if (fileType.startsWith("image/")) {
const fileBlob = new Blob([fileBuffer], { type: fileType });
url = URL.createObjectURL(fileBlob);
try {
return await generateImageThumbnail(url);
} catch {
URL.revokeObjectURL(url);
url = undefined;
if (fileType === "image/heic") {
const { default: heic2any } = await import("heic2any");
url = URL.createObjectURL(
(await heic2any({ blob: fileBlob, toType: "image/png" })) as Blob,
);
return await generateImageThumbnail(url);
} else {
return null;
}
}
} else if (fileType.startsWith("video/")) {
url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
return await generateVideoThumbnail(url);
}
return null;
} catch {
return null;
} finally {
if (url) {
URL.revokeObjectURL(url);
}
}
};
export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => {
return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`;
};

View File

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

View File

@@ -1,158 +1,106 @@
import pg from "pg"; import { SqliteError } from "better-sqlite3";
import { and, or, eq, gt, lte } from "drizzle-orm";
import db from "./drizzle";
import { IntegrityError } from "./error"; import { IntegrityError } from "./error";
import db from "./kysely"; import { client, userClient, userClientChallenge } from "./schema";
import type { UserClientState } from "./schema";
interface Client {
id: number;
encPubKey: string;
sigPubKey: string;
}
interface UserClient {
userId: number;
clientId: number;
state: UserClientState;
}
interface UserClientWithDetails extends UserClient {
encPubKey: string;
sigPubKey: string;
}
export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => { export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => {
return await db return await db.transaction(
.transaction() async (tx) => {
.setIsolationLevel("serializable") const clients = await tx
.execute(async (trx) => { .select({ id: client.id })
const client = await trx .from(client)
.selectFrom("client") .where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey)))
.where((eb) => .limit(1);
eb.or([ if (clients.length !== 0) {
eb("encryption_public_key", "=", encPubKey),
eb("encryption_public_key", "=", sigPubKey),
eb("signature_public_key", "=", encPubKey),
eb("signature_public_key", "=", sigPubKey),
]),
)
.limit(1)
.executeTakeFirst();
if (client) {
throw new IntegrityError("Public key(s) already registered"); throw new IntegrityError("Public key(s) already registered");
} }
const { clientId } = await trx const newClients = await tx
.insertInto("client") .insert(client)
.values({ encryption_public_key: encPubKey, signature_public_key: sigPubKey }) .values({ encPubKey, sigPubKey })
.returning("id as clientId") .returning({ id: client.id });
.executeTakeFirstOrThrow(); const { id: clientId } = newClients[0]!;
await trx await tx.insert(userClient).values({ userId, clientId });
.insertInto("user_client")
.values({ user_id: userId, client_id: clientId }) return clientId;
.execute(); },
return { id: clientId }; { behavior: "exclusive" },
}); );
}; };
export const getClient = async (clientId: number) => { export const getClient = async (clientId: number) => {
const client = await db const clients = await db.select().from(client).where(eq(client.id, clientId)).limit(1);
.selectFrom("client") return clients[0] ?? null;
.selectAll()
.where("id", "=", clientId)
.limit(1)
.executeTakeFirst();
return client
? ({
id: client.id,
encPubKey: client.encryption_public_key,
sigPubKey: client.signature_public_key,
} satisfies Client)
: null;
}; };
export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) => { export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) => {
const client = await db const clients = await db
.selectFrom("client") .select()
.selectAll() .from(client)
.where("encryption_public_key", "=", encPubKey) .where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey)))
.where("signature_public_key", "=", sigPubKey) .limit(1);
.limit(1) return clients[0] ?? null;
.executeTakeFirst();
return client
? ({
id: client.id,
encPubKey: client.encryption_public_key,
sigPubKey: client.signature_public_key,
} satisfies Client)
: null;
}; };
export const createUserClient = async (userId: number, clientId: number) => { export const createUserClient = async (userId: number, clientId: number) => {
try { try {
await db.insertInto("user_client").values({ user_id: userId, client_id: clientId }).execute(); await db.insert(userClient).values({ userId, clientId });
} catch (e) { } catch (e) {
if (e instanceof pg.DatabaseError && e.code === "23505") { if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
throw new IntegrityError("User client already exists"); throw new IntegrityError("User client already exists");
} }
throw e; throw e;
} }
}; };
export const getAllUserClients = async (userId: number) => {
return await db.select().from(userClient).where(eq(userClient.userId, userId));
};
export const getUserClient = async (userId: number, clientId: number) => { export const getUserClient = async (userId: number, clientId: number) => {
const userClient = await db const userClients = await db
.selectFrom("user_client") .select()
.selectAll() .from(userClient)
.where("user_id", "=", userId) .where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
.where("client_id", "=", clientId) .limit(1);
.limit(1) return userClients[0] ?? null;
.executeTakeFirst();
return userClient
? ({
userId: userClient.user_id,
clientId: userClient.client_id,
state: userClient.state,
} satisfies UserClient)
: null;
}; };
export const getUserClientWithDetails = async (userId: number, clientId: number) => { export const getUserClientWithDetails = async (userId: number, clientId: number) => {
const userClient = await db const userClients = await db
.selectFrom("user_client") .select()
.innerJoin("client", "user_client.client_id", "client.id") .from(userClient)
.selectAll() .innerJoin(client, eq(userClient.clientId, client.id))
.where("user_id", "=", userId) .where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
.where("client_id", "=", clientId) .limit(1);
.limit(1) return userClients[0] ?? null;
.executeTakeFirst();
return userClient
? ({
userId: userClient.user_id,
clientId: userClient.client_id,
state: userClient.state,
encPubKey: userClient.encryption_public_key,
sigPubKey: userClient.signature_public_key,
} satisfies UserClientWithDetails)
: null;
}; };
export const setUserClientStateToPending = async (userId: number, clientId: number) => { export const setUserClientStateToPending = async (userId: number, clientId: number) => {
await db await db
.updateTable("user_client") .update(userClient)
.set({ state: "pending" }) .set({ state: "pending" })
.where("user_id", "=", userId) .where(
.where("client_id", "=", clientId) and(
.where("state", "=", "challenging") eq(userClient.userId, userId),
.execute(); eq(userClient.clientId, clientId),
eq(userClient.state, "challenging"),
),
);
}; };
export const setUserClientStateToActive = async (userId: number, clientId: number) => { export const setUserClientStateToActive = async (userId: number, clientId: number) => {
await db await db
.updateTable("user_client") .update(userClient)
.set({ state: "active" }) .set({ state: "active" })
.where("user_id", "=", userId) .where(
.where("client_id", "=", clientId) and(
.where("state", "=", "pending") eq(userClient.userId, userId),
.execute(); eq(userClient.clientId, clientId),
eq(userClient.state, "pending"),
),
);
}; };
export const registerUserClientChallenge = async ( export const registerUserClientChallenge = async (
@@ -162,36 +110,30 @@ export const registerUserClientChallenge = async (
allowedIp: string, allowedIp: string,
expiresAt: Date, expiresAt: Date,
) => { ) => {
const { id } = await db await db.insert(userClientChallenge).values({
.insertInto("user_client_challenge") userId,
.values({ clientId,
user_id: userId, answer,
client_id: clientId, allowedIp,
answer, expiresAt,
allowed_ip: allowedIp, });
expires_at: expiresAt,
})
.returning("id")
.executeTakeFirstOrThrow();
return { id };
}; };
export const consumeUserClientChallenge = async ( export const consumeUserClientChallenge = async (userId: number, answer: string, ip: string) => {
challengeId: number, const challenges = await db
userId: number, .delete(userClientChallenge)
ip: string, .where(
) => { and(
const challenge = await db eq(userClientChallenge.userId, userId),
.deleteFrom("user_client_challenge") eq(userClientChallenge.answer, answer),
.where("id", "=", challengeId) eq(userClientChallenge.allowedIp, ip),
.where("user_id", "=", userId) gt(userClientChallenge.expiresAt, new Date()),
.where("allowed_ip", "=", ip) ),
.where("expires_at", ">", new Date()) )
.returning(["client_id", "answer"]) .returning({ clientId: userClientChallenge.clientId });
.executeTakeFirst(); return challenges[0] ?? null;
return challenge ? { clientId: challenge.client_id, answer: challenge.answer } : null;
}; };
export const cleanupExpiredUserClientChallenges = async () => { export const cleanupExpiredUserClientChallenges = async () => {
await db.deleteFrom("user_client_challenge").where("expires_at", "<=", new Date()).execute(); await db.delete(userClientChallenge).where(lte(userClientChallenge.expiresAt, new Date()));
}; };

View File

@@ -0,0 +1,15 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import env from "$lib/server/loadenv";
const client = new Database(env.databaseUrl);
const db = drizzle(client);
export const migrateDB = () => {
if (process.env.NODE_ENV === "production") {
migrate(db, { migrationsFolder: "./drizzle" });
}
};
export default db;

View File

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

View File

@@ -1,25 +1,21 @@
import { sql, type NotNull } from "kysely"; import { and, eq, isNull } from "drizzle-orm";
import pg from "pg"; import db from "./drizzle";
import { IntegrityError } from "./error"; import { IntegrityError } from "./error";
import db from "./kysely"; import { directory, directoryLog, file, fileLog, hsk, mek } from "./schema";
import type { Ciphertext } from "./schema";
export type DirectoryId = "root" | number; type DirectoryId = "root" | number;
interface Directory { export interface NewDirectoryParams {
id: number;
parentId: DirectoryId; parentId: DirectoryId;
userId: number; userId: number;
mekVersion: number; mekVersion: number;
encDek: string; encDek: string;
dekVersion: Date; dekVersion: Date;
encName: Ciphertext; encName: string;
encNameIv: string;
} }
export type NewDirectory = Omit<Directory, "id">; export interface NewFileParams {
interface File {
id: number;
parentId: DirectoryId; parentId: DirectoryId;
userId: number; userId: number;
path: string; path: string;
@@ -30,320 +26,198 @@ interface File {
contentHmac: string | null; contentHmac: string | null;
contentType: string; contentType: string;
encContentIv: string; encContentIv: string;
encContentHash: string; encName: string;
encName: Ciphertext; encNameIv: string;
encCreatedAt: Ciphertext | null;
encLastModifiedAt: Ciphertext;
} }
export type NewFile = Omit<File, "id">; export const registerDirectory = async (params: NewDirectoryParams) => {
await db.transaction(
async (tx) => {
const meks = await tx
.select({ version: mek.version })
.from(mek)
.where(and(eq(mek.userId, params.userId), eq(mek.state, "active")))
.limit(1);
if (meks[0]?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
export const registerDirectory = async (params: NewDirectory) => { const newDirectories = await tx
await db.transaction().execute(async (trx) => { .insert(directory)
const mek = await trx .values({
.selectFrom("master_encryption_key") parentId: params.parentId === "root" ? null : params.parentId,
.select("version") userId: params.userId,
.where("user_id", "=", params.userId) mekVersion: params.mekVersion,
.where("state", "=", "active") encDek: params.encDek,
.limit(1) dekVersion: params.dekVersion,
.forUpdate() encName: { ciphertext: params.encName, iv: params.encNameIv },
.executeTakeFirst(); })
if (mek?.version !== params.mekVersion) { .returning({ id: directory.id });
throw new IntegrityError("Inactive MEK version"); const { id: directoryId } = newDirectories[0]!;
} await tx.insert(directoryLog).values({
directoryId,
const { directoryId } = await trx
.insertInto("directory")
.values({
parent_id: params.parentId !== "root" ? params.parentId : null,
user_id: params.userId,
master_encryption_key_version: params.mekVersion,
encrypted_data_encryption_key: params.encDek,
data_encryption_key_version: params.dekVersion,
encrypted_name: params.encName,
})
.returning("id as directoryId")
.executeTakeFirstOrThrow();
await trx
.insertInto("directory_log")
.values({
directory_id: directoryId,
timestamp: new Date(), timestamp: new Date(),
action: "create", action: "create",
new_name: params.encName, newName: { ciphertext: params.encName, iv: params.encNameIv },
}) });
.execute(); },
}); { behavior: "exclusive" },
};
export const getAllDirectoriesByParent = async (userId: number, parentId: DirectoryId) => {
let query = db.selectFrom("directory").selectAll().where("user_id", "=", userId);
query =
parentId === "root"
? query.where("parent_id", "is", null)
: query.where("parent_id", "=", parentId);
const directories = await query.execute();
return directories.map(
(directory) =>
({
id: directory.id,
parentId: directory.parent_id ?? "root",
userId: directory.user_id,
mekVersion: directory.master_encryption_key_version,
encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name,
}) satisfies Directory,
); );
}; };
export const getAllDirectoriesByParent = async (userId: number, parentId: DirectoryId) => {
return await db
.select()
.from(directory)
.where(
and(
eq(directory.userId, userId),
parentId === "root" ? isNull(directory.parentId) : eq(directory.parentId, parentId),
),
);
};
export const getDirectory = async (userId: number, directoryId: number) => { export const getDirectory = async (userId: number, directoryId: number) => {
const directory = await db const res = await db
.selectFrom("directory") .select()
.selectAll() .from(directory)
.where("id", "=", directoryId) .where(and(eq(directory.userId, userId), eq(directory.id, directoryId)))
.where("user_id", "=", userId) .limit(1);
.limit(1) return res[0] ?? null;
.executeTakeFirst();
return directory
? ({
id: directory.id,
parentId: directory.parent_id ?? "root",
userId: directory.user_id,
mekVersion: directory.master_encryption_key_version,
encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name,
} satisfies Directory)
: null;
}; };
export const setDirectoryEncName = async ( export const setDirectoryEncName = async (
userId: number, userId: number,
directoryId: number, directoryId: number,
dekVersion: Date, dekVersion: Date,
encName: Ciphertext, encName: string,
encNameIv: string,
) => { ) => {
await db.transaction().execute(async (trx) => { await db.transaction(
const directory = await trx async (tx) => {
.selectFrom("directory") const directories = await tx
.select("data_encryption_key_version") .select({ version: directory.dekVersion })
.where("id", "=", directoryId) .from(directory)
.where("user_id", "=", userId) .where(and(eq(directory.userId, userId), eq(directory.id, directoryId)))
.limit(1) .limit(1);
.forUpdate() if (!directories[0]) {
.executeTakeFirst(); throw new IntegrityError("Directory not found");
if (!directory) { } else if (directories[0].version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Directory not found"); throw new IntegrityError("Invalid DEK version");
} else if (directory.data_encryption_key_version.getTime() !== dekVersion.getTime()) { }
throw new IntegrityError("Invalid DEK version");
}
await trx await tx
.updateTable("directory") .update(directory)
.set({ encrypted_name: encName }) .set({ encName: { ciphertext: encName, iv: encNameIv } })
.where("id", "=", directoryId) .where(and(eq(directory.userId, userId), eq(directory.id, directoryId)));
.where("user_id", "=", userId) await tx.insert(directoryLog).values({
.execute(); directoryId,
await trx
.insertInto("directory_log")
.values({
directory_id: directoryId,
timestamp: new Date(), timestamp: new Date(),
action: "rename", action: "rename",
new_name: encName, newName: { ciphertext: encName, iv: encNameIv },
}) });
.execute(); },
}); { behavior: "exclusive" },
);
}; };
export const unregisterDirectory = async (userId: number, directoryId: number) => { export const unregisterDirectory = async (userId: number, directoryId: number) => {
return await db return await db.transaction(
.transaction() async (tx) => {
.setIsolationLevel("repeatable read") // TODO: Sufficient?
.execute(async (trx) => {
const unregisterFiles = async (parentId: number) => { const unregisterFiles = async (parentId: number) => {
const files = await trx const files = await tx
.selectFrom("file") .delete(file)
.leftJoin("thumbnail", "file.id", "thumbnail.file_id") .where(and(eq(file.userId, userId), eq(file.parentId, parentId)))
.select(["file.id", "file.path", "thumbnail.path as thumbnailPath"]) .returning({ path: file.path });
.where("file.parent_id", "=", parentId) return files.map(({ path }) => path);
.where("file.user_id", "=", userId)
.forUpdate("file")
.execute();
await trx
.deleteFrom("file")
.where("parent_id", "=", parentId)
.where("user_id", "=", userId)
.execute();
return files;
}; };
const unregisterDirectoryRecursively = async ( const unregisterDirectoryRecursively = async (directoryId: number): Promise<string[]> => {
directoryId: number, const filePaths = await unregisterFiles(directoryId);
): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => { const subDirectories = await tx
const files = await unregisterFiles(directoryId); .select({ id: directory.id })
const subDirectories = await trx .from(directory)
.selectFrom("directory") .where(and(eq(directory.userId, userId), eq(directory.parentId, directoryId)));
.select("id")
.where("parent_id", "=", directoryId)
.where("user_id", "=", userId)
.execute();
const subDirectoryFilePaths = await Promise.all( const subDirectoryFilePaths = await Promise.all(
subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)), subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
); );
const deleteRes = await trx const deleteRes = await tx.delete(directory).where(eq(directory.id, directoryId));
.deleteFrom("directory") if (deleteRes.changes === 0) {
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.executeTakeFirst();
if (deleteRes.numDeletedRows === 0n) {
throw new IntegrityError("Directory not found"); throw new IntegrityError("Directory not found");
} }
return files.concat(...subDirectoryFilePaths); return filePaths.concat(...subDirectoryFilePaths);
}; };
return await unregisterDirectoryRecursively(directoryId); return await unregisterDirectoryRecursively(directoryId);
}); },
{ behavior: "exclusive" },
);
}; };
export const registerFile = async (params: NewFile) => { export const registerFile = async (params: NewFileParams) => {
if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) { if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) {
throw new Error("Invalid arguments"); throw new Error("Invalid arguments");
} }
return await db.transaction().execute(async (trx) => { await db.transaction(
const mek = await trx async (tx) => {
.selectFrom("master_encryption_key") const meks = await tx
.select("version") .select({ version: mek.version })
.where("user_id", "=", params.userId) .from(mek)
.where("state", "=", "active") .where(and(eq(mek.userId, params.userId), eq(mek.state, "active")))
.limit(1) .limit(1);
.forUpdate() if (meks[0]?.version !== params.mekVersion) {
.executeTakeFirst(); throw new IntegrityError("Inactive MEK version");
if (mek?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
if (params.hskVersion) {
const hsk = await trx
.selectFrom("hmac_secret_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (hsk?.version !== params.hskVersion) {
throw new IntegrityError("Inactive HSK version");
} }
}
const { fileId } = await trx if (params.hskVersion) {
.insertInto("file") const hsks = await tx
.values({ .select({ version: hsk.version })
parent_id: params.parentId !== "root" ? params.parentId : null, .from(hsk)
user_id: params.userId, .where(and(eq(hsk.userId, params.userId), eq(hsk.state, "active")))
path: params.path, .limit(1);
master_encryption_key_version: params.mekVersion, if (hsks[0]?.version !== params.hskVersion) {
encrypted_data_encryption_key: params.encDek, throw new IntegrityError("Inactive HSK version");
data_encryption_key_version: params.dekVersion, }
hmac_secret_key_version: params.hskVersion, }
content_hmac: params.contentHmac,
content_type: params.contentType, const newFiles = await tx
encrypted_content_iv: params.encContentIv, .insert(file)
encrypted_content_hash: params.encContentHash, .values({
encrypted_name: params.encName, path: params.path,
encrypted_created_at: params.encCreatedAt, parentId: params.parentId === "root" ? null : params.parentId,
encrypted_last_modified_at: params.encLastModifiedAt, userId: params.userId,
}) mekVersion: params.mekVersion,
.returning("id as fileId") hskVersion: params.hskVersion,
.executeTakeFirstOrThrow(); contentHmac: params.contentHmac,
await trx contentType: params.contentType,
.insertInto("file_log") encDek: params.encDek,
.values({ dekVersion: params.dekVersion,
file_id: fileId, encContentIv: params.encContentIv,
encName: { ciphertext: params.encName, iv: params.encNameIv },
})
.returning({ id: file.id });
const { id: fileId } = newFiles[0]!;
await tx.insert(fileLog).values({
fileId,
timestamp: new Date(), timestamp: new Date(),
action: "create", action: "create",
new_name: params.encName, newName: { ciphertext: params.encName, iv: params.encNameIv },
}) });
.execute(); },
return { id: fileId }; { behavior: "exclusive" },
});
};
export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => {
let query = db.selectFrom("file").selectAll().where("user_id", "=", userId);
query =
parentId === "root"
? query.where("parent_id", "is", null)
: query.where("parent_id", "=", parentId);
const files = await query.execute();
return files.map(
(file) =>
({
id: file.id,
parentId: file.parent_id ?? "root",
userId: file.user_id,
path: file.path,
mekVersion: file.master_encryption_key_version,
encDek: file.encrypted_data_encryption_key,
dekVersion: file.data_encryption_key_version,
hskVersion: file.hmac_secret_key_version,
contentHmac: file.content_hmac,
contentType: file.content_type,
encContentIv: file.encrypted_content_iv,
encContentHash: file.encrypted_content_hash,
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
}) satisfies File,
); );
}; };
export const getAllFilesByCategory = async ( export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => {
userId: number, return await db
categoryId: number, .select()
recurse: boolean, .from(file)
) => { .where(
const files = await db and(
.withRecursive("cte", (db) => eq(file.userId, userId),
db parentId === "root" ? isNull(file.parentId) : eq(file.parentId, parentId),
.selectFrom("category") ),
.leftJoin("file_category", "category.id", "file_category.category_id") );
.select(["id", "parent_id", "user_id", "file_category.file_id"])
.select(sql<number>`0`.as("depth"))
.where("id", "=", categoryId)
.$if(recurse, (qb) =>
qb.unionAll((db) =>
db
.selectFrom("category")
.leftJoin("file_category", "category.id", "file_category.category_id")
.innerJoin("cte", "category.parent_id", "cte.id")
.select([
"category.id",
"category.parent_id",
"category.user_id",
"file_category.file_id",
])
.select(sql<number>`cte.depth + 1`.as("depth")),
),
),
)
.selectFrom("cte")
.select(["file_id", "depth"])
.distinctOn("file_id")
.where("user_id", "=", userId)
.where("file_id", "is not", null)
.$narrowType<{ file_id: NotNull }>()
.orderBy("file_id")
.orderBy("depth")
.execute();
return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 }));
};
export const getAllFileIds = async (userId: number) => {
const files = await db.selectFrom("file").select("id").where("user_id", "=", userId).execute();
return files.map(({ id }) => id);
}; };
export const getAllFileIdsByContentHmac = async ( export const getAllFileIdsByContentHmac = async (
@@ -351,156 +225,69 @@ export const getAllFileIdsByContentHmac = async (
hskVersion: number, hskVersion: number,
contentHmac: string, contentHmac: string,
) => { ) => {
const files = await db return await db
.selectFrom("file") .select({ id: file.id })
.select("id") .from(file)
.where("user_id", "=", userId) .where(
.where("hmac_secret_key_version", "=", hskVersion) and(
.where("content_hmac", "=", contentHmac) eq(file.userId, userId),
.execute(); eq(file.hskVersion, hskVersion),
return files.map(({ id }) => id); eq(file.contentHmac, contentHmac),
),
);
}; };
export const getFile = async (userId: number, fileId: number) => { export const getFile = async (userId: number, fileId: number) => {
const file = await db const res = await db
.selectFrom("file") .select()
.selectAll() .from(file)
.where("id", "=", fileId) .where(and(eq(file.userId, userId), eq(file.id, fileId)))
.where("user_id", "=", userId) .limit(1);
.limit(1) return res[0] ?? null;
.executeTakeFirst();
return file
? ({
id: file.id,
parentId: file.parent_id ?? "root",
userId: file.user_id,
path: file.path,
mekVersion: file.master_encryption_key_version,
encDek: file.encrypted_data_encryption_key,
dekVersion: file.data_encryption_key_version,
hskVersion: file.hmac_secret_key_version,
contentHmac: file.content_hmac,
contentType: file.content_type,
encContentIv: file.encrypted_content_iv,
encContentHash: file.encrypted_content_hash,
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
} satisfies File)
: null;
}; };
export const setFileEncName = async ( export const setFileEncName = async (
userId: number, userId: number,
fileId: number, fileId: number,
dekVersion: Date, dekVersion: Date,
encName: Ciphertext, encName: string,
encNameIv: string,
) => { ) => {
await db.transaction().execute(async (trx) => { await db.transaction(
const file = await trx async (tx) => {
.selectFrom("file") const files = await tx
.select("data_encryption_key_version") .select({ version: file.dekVersion })
.where("id", "=", fileId) .from(file)
.where("user_id", "=", userId) .where(and(eq(file.userId, userId), eq(file.id, fileId)))
.limit(1) .limit(1);
.forUpdate() if (!files[0]) {
.executeTakeFirst(); throw new IntegrityError("File not found");
if (!file) { } else if (files[0].version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("File not found"); throw new IntegrityError("Invalid DEK version");
} else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) { }
throw new IntegrityError("Invalid DEK version");
}
await trx await tx
.updateTable("file") .update(file)
.set({ encrypted_name: encName }) .set({ encName: { ciphertext: encName, iv: encNameIv } })
.where("id", "=", fileId) .where(and(eq(file.userId, userId), eq(file.id, fileId)));
.where("user_id", "=", userId) await tx.insert(fileLog).values({
.execute(); fileId,
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(), timestamp: new Date(),
action: "rename", action: "rename",
new_name: encName, newName: { ciphertext: encName, iv: encNameIv },
}) });
.execute(); },
}); { behavior: "exclusive" },
);
}; };
export const unregisterFile = async (userId: number, fileId: number) => { export const unregisterFile = async (userId: number, fileId: number) => {
return await db.transaction().execute(async (trx) => { const files = await db
const file = await trx .delete(file)
.selectFrom("file") .where(and(eq(file.userId, userId), eq(file.id, fileId)))
.leftJoin("thumbnail", "file.id", "thumbnail.file_id") .returning({ path: file.path });
.select(["file.path", "thumbnail.path as thumbnailPath"]) if (!files[0]) {
.where("file.id", "=", fileId) throw new IntegrityError("File not found");
.where("file.user_id", "=", userId) }
.forUpdate("file") return files[0].path;
.executeTakeFirst();
if (!file) {
throw new IntegrityError("File not found");
}
await trx.deleteFrom("file").where("id", "=", fileId).execute();
return file;
});
};
export const addFileToCategory = async (fileId: number, categoryId: number) => {
await db.transaction().execute(async (trx) => {
try {
await trx
.insertInto("file_category")
.values({ file_id: fileId, category_id: categoryId })
.execute();
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: "add-to-category",
category_id: categoryId,
})
.execute();
} catch (e) {
if (e instanceof pg.DatabaseError && e.code === "23505") {
throw new IntegrityError("File already added to category");
}
throw e;
}
});
};
export const getAllFileCategories = async (fileId: number) => {
const categories = await db
.selectFrom("file_category")
.select("category_id")
.where("file_id", "=", fileId)
.execute();
return categories.map(({ category_id }) => ({ id: category_id }));
};
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {
await db.transaction().execute(async (trx) => {
const res = await trx
.deleteFrom("file_category")
.where("file_id", "=", fileId)
.where("category_id", "=", categoryId)
.executeTakeFirst();
if (res.numDeletedRows === 0n) {
throw new IntegrityError("File not found in category");
}
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: "remove-from-category",
category_id: categoryId,
})
.execute();
});
}; };

View File

@@ -1,15 +1,8 @@
import pg from "pg"; import { SqliteError } from "better-sqlite3";
import { and, eq } from "drizzle-orm";
import db from "./drizzle";
import { IntegrityError } from "./error"; import { IntegrityError } from "./error";
import db from "./kysely"; import { hsk, hskLog } from "./schema";
import type { HskState } from "./schema";
interface Hsk {
userId: number;
version: number;
state: HskState;
mekVersion: number;
encHsk: string;
}
export const registerInitialHsk = async ( export const registerInitialHsk = async (
userId: number, userId: number,
@@ -17,52 +10,37 @@ export const registerInitialHsk = async (
mekVersion: number, mekVersion: number,
encHsk: string, encHsk: string,
) => { ) => {
await db.transaction().execute(async (trx) => { await db.transaction(
try { async (tx) => {
await trx try {
.insertInto("hmac_secret_key") await tx.insert(hsk).values({
.values({ userId,
user_id: userId,
version: 1, version: 1,
state: "active", state: "active",
master_encryption_key_version: mekVersion, mekVersion,
encrypted_key: encHsk, encHsk,
}) });
.execute(); await tx.insert(hskLog).values({
await trx userId,
.insertInto("hmac_secret_key_log") hskVersion: 1,
.values({
user_id: userId,
hmac_secret_key_version: 1,
timestamp: new Date(), timestamp: new Date(),
action: "create", action: "create",
action_by: createdBy, actionBy: createdBy,
}) });
.execute(); } catch (e) {
} catch (e) { if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
if (e instanceof pg.DatabaseError && e.code === "23505") { throw new IntegrityError("HSK already registered");
throw new IntegrityError("HSK already registered"); }
throw e;
} }
throw e; },
} { behavior: "exclusive" },
}); );
}; };
export const getAllValidHsks = async (userId: number) => { export const getAllValidHsks = async (userId: number) => {
const hsks = await db return await db
.selectFrom("hmac_secret_key") .select()
.selectAll() .from(hsk)
.where("user_id", "=", userId) .where(and(eq(hsk.userId, userId), eq(hsk.state, "active")));
.where("state", "=", "active")
.execute();
return hsks.map(
({ user_id, version, state, master_encryption_key_version, encrypted_key }) =>
({
userId: user_id,
version,
state: state as "active",
mekVersion: master_encryption_key_version,
encHsk: encrypted_key,
}) satisfies Hsk,
);
}; };

View File

@@ -1,10 +0,0 @@
export * as CategoryRepo from "./category";
export * as ClientRepo from "./client";
export * as FileRepo from "./file";
export * as HskRepo from "./hsk";
export * as MediaRepo from "./media";
export * as MekRepo from "./mek";
export * as SessionRepo from "./session";
export * as UserRepo from "./user";
export * from "./error";

View File

@@ -1,47 +0,0 @@
import { Kysely, PostgresDialect, Migrator } from "kysely";
import pg from "pg";
import env from "$lib/server/loadenv";
import migrations from "./migrations";
import type { Database } from "./schema";
const dialect = new PostgresDialect({
pool: new pg.Pool({
host: env.database.host,
port: env.database.port,
user: env.database.user,
password: env.database.password,
database: env.database.name,
}),
});
const db = new Kysely<Database>({ dialect });
export const migrateDB = async () => {
if (env.nodeEnv !== "production") return;
const migrator = new Migrator({
db,
provider: {
async getMigrations() {
return migrations;
},
},
});
const { error, results } = await migrator.migrateToLatest();
if (error) {
const migration = results?.find(({ status }) => status === "Error");
if (migration) {
console.error(`Migration "${migration.migrationName}" failed.`);
}
console.error(error);
process.exit(1);
}
if (results?.length === 0) {
console.log("Database is up-to-date.");
} else {
console.log("Database migration completed.");
}
};
export default db;

View File

@@ -1,110 +0,0 @@
import type { NotNull } from "kysely";
import { IntegrityError } from "./error";
import db from "./kysely";
interface Thumbnail {
id: number;
path: string;
updatedAt: Date;
encContentIv: string;
}
interface FileThumbnail extends Thumbnail {
fileId: number;
}
export const updateFileThumbnail = async (
userId: number,
fileId: number,
dekVersion: Date,
path: string,
encContentIv: string,
) => {
return await db.transaction().execute(async (trx) => {
const file = await trx
.selectFrom("file")
.select("data_encryption_key_version")
.where("id", "=", fileId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!file) {
throw new IntegrityError("File not found");
} else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
}
const thumbnail = await trx
.selectFrom("thumbnail")
.select("path as oldPath")
.where("file_id", "=", fileId)
.limit(1)
.forUpdate()
.executeTakeFirst();
const now = new Date();
await trx
.insertInto("thumbnail")
.values({
file_id: fileId,
path,
updated_at: now,
encrypted_content_iv: encContentIv,
})
.onConflict((oc) =>
oc.column("file_id").doUpdateSet({
path,
updated_at: now,
encrypted_content_iv: encContentIv,
}),
)
.execute();
return thumbnail?.oldPath ?? null;
});
};
export const getFileThumbnail = async (userId: number, fileId: number) => {
const thumbnail = await db
.selectFrom("thumbnail")
.innerJoin("file", "thumbnail.file_id", "file.id")
.selectAll("thumbnail")
.where("file.id", "=", fileId)
.where("file.user_id", "=", userId)
.$narrowType<{ file_id: NotNull }>()
.limit(1)
.executeTakeFirst();
return thumbnail
? ({
id: thumbnail.id,
fileId: thumbnail.file_id,
path: thumbnail.path,
encContentIv: thumbnail.encrypted_content_iv,
updatedAt: thumbnail.updated_at,
} satisfies FileThumbnail)
: null;
};
export const getMissingFileThumbnails = async (userId: number, limit: number = 100) => {
const files = await db
.selectFrom("file")
.select("id")
.where("user_id", "=", userId)
.where((eb) =>
eb.or([eb("content_type", "like", "image/%"), eb("content_type", "like", "video/%")]),
)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom("thumbnail")
.select("thumbnail.id")
.whereRef("thumbnail.file_id", "=", "file.id")
.limit(1),
),
),
)
.limit(limit)
.execute();
return files.map(({ id }) => id);
};

Some files were not shown because too many files have changed in this diff Show More