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
254 changed files with 7093 additions and 10291 deletions

View File

@@ -1,6 +1,5 @@
.git
node_modules
/Makefile
# Output
.output
@@ -11,15 +10,13 @@ node_modules
/build
/data
/library
/thumbnails
# OS
.DS_Store
Thumbs.db
# Editors
# VSCode
/.vscode
/.idea
# Env
.env
@@ -30,3 +27,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# SQLite
*.db

View File

@@ -1,14 +1,9 @@
# Required environment variables
DATABASE_PASSWORD=
SESSION_SECRET=
# Optional environment variables
DATABASE_HOST=
DATABASE_PORT=
DATABASE_USER=
DATABASE_NAME=
DATABASE_URL=
SESSION_EXPIRES=
USER_CLIENT_CHALLENGE_EXPIRES=
SESSION_UPGRADE_CHALLENGE_EXPIRES=
LIBRARY_PATH=
THUMBNAILS_PATH=

7
.gitignore vendored
View File

@@ -9,15 +9,13 @@ node_modules
/build
/data
/library
/thumbnails
# OS
.DS_Store
Thumbs.db
# Editors
# VSCode
/.vscode
/.idea
# Env
.env
@@ -28,3 +26,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# SQLite
*.db

View File

@@ -3,5 +3,8 @@ package-lock.json
pnpm-lock.yaml
yarn.lock
# Output
/drizzle
# Documents
*.md

View File

@@ -2,7 +2,7 @@
FROM node:22-alpine AS base
WORKDIR /app
RUN npm install -g pnpm@10
RUN npm install -g pnpm@9
COPY pnpm-lock.yaml .
# Build Stage
@@ -10,9 +10,8 @@ FROM base AS build
RUN pnpm fetch
COPY . .
RUN pnpm install --offline && \
pnpm build && \
sed -i "s/http\.createServer()/http.createServer({ requestTimeout: 0 })/g" ./build/index.js
RUN pnpm install --offline
RUN pnpm build
# Deploy Stage
FROM base
@@ -22,7 +21,9 @@ COPY package.json .
RUN pnpm install --offline --prod
COPY --from=build /app/build ./build
COPY drizzle ./drizzle
EXPOSE 3000
ENV BODY_SIZE_LIMIT=Infinity
CMD ["node", "./build/index.js"]

View File

@@ -23,7 +23,7 @@ vim .env # 아래를 참고하여 환경 변수를 설정해 주세요.
docker compose up --build -d
```
모든 데이터는 `./data` 디렉터리에 아래에 저장될 거예요.
모든 데이터는 `./data` 디렉터리에 저장될 거예요.
### 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은 마지막으로 사용된 후 설정된 유효 시간이 지나면 자동으로 삭제돼요.|
|`USER_CLIENT_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:
build: .
restart: unless-stopped
depends_on:
database:
condition: service_healthy
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
volumes:
- ./data/library:/app/data/library
- ./data/thumbnails:/app/data/thumbnails
- ./data:/app/data
environment:
# ArkVault
- DATABASE_HOST=database
- DATABASE_USER=arkvault
- DATABASE_PASSWORD=${DATABASE_PASSWORD:?} # Required
- DATABASE_URL=/app/data/database.sqlite
- SESSION_SECRET=${SESSION_SECRET:?} # Required
- SESSION_EXPIRES
- USER_CLIENT_CHALLENGE_EXPIRES
- SESSION_UPGRADE_CHALLENGE_EXPIRES
- LIBRARY_PATH=/app/data/library
- THUMBNAILS_PATH=/app/data/thumbnails
# SvelteKit
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
- XFF_DEPTH=${TRUST_PROXY:-}
- NODE_ENV=${NODE_ENV:-production}
ports:
- ${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 js from "@eslint/js";
import { includeIgnoreFile } from "@eslint/compat";
import svelte from "eslint-plugin-svelte";
import tailwind from "eslint-plugin-tailwindcss";
import globals from "globals";
import { fileURLToPath } from "node:url";
import ts from "typescript-eslint";
import { fileURLToPath } from "url";
import svelteConfig from "./svelte.config.js";
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default defineConfig(
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
...svelte.configs["flat/recommended"],
...tailwind.configs["flat/recommended"],
prettier,
...svelte.configs.prettier,
...svelte.configs["flat/prettier"],
{
languageOptions: {
globals: {
@@ -26,18 +23,13 @@ export default defineConfig(
...globals.node,
},
},
rules: {
"no-undef": "off",
},
},
{
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
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",
"private": true,
"version": "0.6.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -11,62 +11,54 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"db:up": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d",
"db:down": "docker compose -f docker-compose.dev.yaml -p arkvault-dev down",
"db:migrate": "kysely migrate"
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@iconify-json/material-symbols": "^1.2.50",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tanstack/svelte-virtual": "^3.13.13",
"@trpc/client": "^11.8.1",
"@eslint/compat": "^1.2.4",
"@iconify-json/material-symbols": "^1.2.12",
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/better-sqlite3": "^7.6.12",
"@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.8",
"@types/pg": "^8.16.0",
"autoprefixer": "^10.4.23",
"axios": "^1.13.2",
"dexie": "^4.2.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.1",
"eslint-plugin-tailwindcss": "^3.18.2",
"exifreader": "^4.33.1",
"@types/node-schedule": "^2.1.7",
"autoprefixer": "^10.4.20",
"dexie": "^4.0.10",
"drizzle-kit": "^0.22.8",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.46.1",
"eslint-plugin-tailwindcss": "^3.17.5",
"file-saver": "^2.0.5",
"globals": "^16.5.0",
"globals": "^15.14.0",
"heic2any": "^0.0.4",
"kysely-ctl": "^0.19.0",
"lru-cache": "^11.2.4",
"mime": "^4.1.0",
"p-limit": "^7.2.0",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.46.1",
"svelte-check": "^4.3.5",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.1",
"unplugin-icons": "^22.5.0",
"vite": "^7.3.0"
"mime": "^4.0.6",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"svelte": "^5.17.1",
"svelte-check": "^4.1.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript-eslint": "^8.19.1",
"unplugin-icons": "^0.22.0",
"vite": "^5.4.11"
},
"dependencies": {
"@fastify/busboy": "^3.2.0",
"@trpc/server": "^11.8.1",
"argon2": "^0.44.0",
"kysely": "^0.28.9",
"argon2": "^0.41.1",
"better-sqlite3": "^11.7.2",
"drizzle-orm": "^0.33.0",
"ms": "^2.1.3",
"node-schedule": "^2.1.1",
"pg": "^8.16.3",
"superjson": "^2.2.6",
"uuid": "^13.0.0",
"zod": "^4.2.1"
"uuid": "^11.0.4",
"zod": "^3.24.1"
},
"engines": {
"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>
<html lang="ko">
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />

View File

@@ -1,18 +1,7 @@
import type { ClientInit } from "@sveltejs/kit";
import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
import { prepareFileCache } from "$lib/modules/file";
import { prepareOpfs } from "$lib/modules/opfs";
import { getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
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 [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([
getClientKey("encrypt"),
@@ -40,14 +29,5 @@ const prepareHmacSecretStore = async () => {
};
export const init: ClientInit = async () => {
await Promise.all([
requestPersistentStorage(),
prepareFileCache(),
prepareClientKeyStore(),
prepareMasterKeyStore(),
prepareHmacSecretStore(),
prepareOpfs(),
]);
cleanupDanglingInfos(); // Intended
await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore(), prepareHmacSecretStore()]);
};

View File

@@ -2,15 +2,15 @@ import type { ServerInit } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import schedule from "node-schedule";
import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
import { migrateDB } from "$lib/server/db/kysely";
import { migrateDB } from "$lib/server/db/drizzle";
import {
cleanupExpiredSessions,
cleanupExpiredSessionUpgradeChallenges,
} from "$lib/server/db/session";
import { authenticate, setAgentInfo } from "$lib/server/middlewares";
export const init: ServerInit = async () => {
await migrateDB();
export const init: ServerInit = () => {
migrateDB();
schedule.scheduleJob("0 * * * *", () => {
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,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,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,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>
<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"
>
<div class="h-full p-1 transition active:scale-95">
{@render children()}
<div class="h-full w-full p-1 transition active:scale-95">
{@render children?.()}
</div>
</button>

View File

@@ -1,4 +1,3 @@
export { default as ActionEntryButton } from "./ActionEntryButton.svelte";
export { default as Button } from "./Button.svelte";
export { default as EntryButton } from "./EntryButton.svelte";
export { default as FloatingButton } from "./FloatingButton.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 BottomDiv } from "./BottomDiv.svelte";
export { default as FullscreenDiv } from "./FullscreenDiv.svelte";
export { default as TitleDiv } from "./TitleDiv.svelte";

View File

@@ -1,5 +1,3 @@
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 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";

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,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="relative aspect-square w-full 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,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,10 +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 FileThumbnailButton } from "./FileThumbnailButton.svelte";
export { default as IconEntryButton } from "./IconEntryButton.svelte";
export * from "./labels";
export { default as SubCategories } from "./SubCategories.svelte";
export { default as TitledDiv } from "./TitledDiv.svelte";
export { default as TopBar } from "./TopBar.svelte";

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,107 +0,0 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { CheckBox } 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>
<div class="space-y-1">
{#key info}
{#each files as { info, isRecursive }}
<File
{info}
onclick={onFileClick}
onRemoveClick={!isRecursive ? onFileRemoveClick : undefined}
/>
{:else}
<p class="text-gray-500 text-center">이 카테고리에 추가된 파일이 없어요.</p>
{/each}
{/key}
</div>
</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,148 +0,0 @@
<script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { FileThumbnailButton } from "$lib/components/molecules";
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"; key: string; label: string }
| { type: "items"; key: string; items: FileEntry[] };
let filesWithDate: FileEntry[] = $state([]);
let rows: Row[] = $state([]);
let listElement: HTMLDivElement | undefined = $state();
const virtualizer = createWindowVirtualizer({
count: 0,
getItemKey: (index) => rows[index]!.key,
estimateSize: () => 1000, // TODO
});
const measureRow = (node: HTMLElement) => {
$virtualizer.measureElement(node);
return {
update: () => $virtualizer.measureElement(node),
};
};
$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",
key: `header-${date}`,
label: formatDate(entries[0]!.date!),
});
newRows.push({
type: "items",
key: `items-${date}`,
items: entries,
});
}
rows = newRows;
$virtualizer.setOptions({ count: rows.length });
};
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>
<div bind:this={listElement} class="relative flex flex-grow flex-col">
<div style="height: {$virtualizer.getTotalSize()}px;">
{#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)}
{@const row = rows[virtualRow.index]!}
<div
use:measureRow
data-index={virtualRow.index}
class="absolute left-0 top-0 w-full"
style="transform: translateY({virtualRow.start}px);"
>
{#if row.type === "header"}
<p class="pb-2 font-medium">{row.label}</p>
{:else}
<div class="grid grid-cols-4 gap-1 pb-4">
{#each row.items as { info }}
<FileThumbnailButton {info} onclick={onFileClick} />
{/each}
</div>
{/if}
</div>
{/each}
</div>
{#if $virtualizer.getVirtualItems().length === 0}
<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>
{/if}
</div>

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);
};
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) => {
const { key: exportedKey, format } = await exportRSAKey(key);
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,370 +0,0 @@
import { TRPCClientError } from "@trpc/client";
import { get, writable, type Writable } from "svelte/store";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
type DirectoryId,
type CategoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import { trpc } from "$trpc/client";
export type DirectoryInfo =
| {
id: "root";
parentId?: undefined;
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
parentId: DirectoryId;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
export type CategoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subCategoryIds: number[];
files?: undefined;
isFileRecursive?: undefined;
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subCategoryIds: number[];
files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
};
const directoryInfoStore = new Map<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 (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteDirectoryInfo(id as number);
return;
}
throw new Error("Failed to fetch directory information");
}
const { metadata, subDirectories: subDirectoryIds, files: fileIds } = data;
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
info.set({
id,
parentId: metadata!.parent,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
});
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
}
};
const fetchDirectoryInfo = async (
id: DirectoryId,
info: Writable<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 (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteFileInfo(id);
return;
}
throw new Error("Failed to fetch file information");
}
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
const createdAt =
metadata.createdAt && metadata.createdAtIv
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
: undefined;
const lastModifiedAt = await decryptDate(
metadata.lastModifiedAt,
metadata.lastModifiedAtIv,
dataKey,
);
info.set({
id,
parentId: metadata.parent,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
};
const fetchFileInfo = async (id: number, info: Writable<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 (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteCategoryInfo(id as number);
return;
}
throw new Error("Failed to fetch category information");
}
const { metadata, subCategories } = data;
if (id === "root") {
info.set({ id, subCategoryIds: subCategories });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
let files;
try {
files = await trpc().category.files.query({ id, recurse: true });
} catch {
throw new Error("Failed to fetch category files");
}
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
let isFileRecursive: boolean | undefined = undefined;
info.update((value) => {
const newValue = {
isFileRecursive: false,
...value,
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subCategoryIds: subCategories,
files: filesMapped,
};
isFileRecursive = newValue.isFileRecursive;
return newValue;
});
await storeCategoryInfo({
id,
parentId: metadata!.parent,
name,
files: filesMapped,
isFileRecursive: isFileRecursive!,
});
}
};
const fetchCategoryInfo = async (
id: CategoryId,
info: Writable<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 db from "./kysely";
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;
}
import { client, userClient, userClientChallenge } from "./schema";
export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => {
return await db
.transaction()
.setIsolationLevel("serializable")
.execute(async (trx) => {
const client = await trx
.selectFrom("client")
.where((eb) =>
eb.or([
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) {
return await db.transaction(
async (tx) => {
const clients = await tx
.select({ id: client.id })
.from(client)
.where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey)))
.limit(1);
if (clients.length !== 0) {
throw new IntegrityError("Public key(s) already registered");
}
const { clientId } = await trx
.insertInto("client")
.values({ encryption_public_key: encPubKey, signature_public_key: sigPubKey })
.returning("id as clientId")
.executeTakeFirstOrThrow();
await trx
.insertInto("user_client")
.values({ user_id: userId, client_id: clientId })
.execute();
return { id: clientId };
});
const newClients = await tx
.insert(client)
.values({ encPubKey, sigPubKey })
.returning({ id: client.id });
const { id: clientId } = newClients[0]!;
await tx.insert(userClient).values({ userId, clientId });
return clientId;
},
{ behavior: "exclusive" },
);
};
export const getClient = async (clientId: number) => {
const client = await db
.selectFrom("client")
.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;
const clients = await db.select().from(client).where(eq(client.id, clientId)).limit(1);
return clients[0] ?? null;
};
export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) => {
const client = await db
.selectFrom("client")
.selectAll()
.where("encryption_public_key", "=", encPubKey)
.where("signature_public_key", "=", sigPubKey)
.limit(1)
.executeTakeFirst();
return client
? ({
id: client.id,
encPubKey: client.encryption_public_key,
sigPubKey: client.signature_public_key,
} satisfies Client)
: null;
const clients = await db
.select()
.from(client)
.where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey)))
.limit(1);
return clients[0] ?? null;
};
export const createUserClient = async (userId: number, clientId: number) => {
try {
await db.insertInto("user_client").values({ user_id: userId, client_id: clientId }).execute();
await db.insert(userClient).values({ userId, clientId });
} 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 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) => {
const userClient = await db
.selectFrom("user_client")
.selectAll()
.where("user_id", "=", userId)
.where("client_id", "=", clientId)
.limit(1)
.executeTakeFirst();
return userClient
? ({
userId: userClient.user_id,
clientId: userClient.client_id,
state: userClient.state,
} satisfies UserClient)
: null;
const userClients = await db
.select()
.from(userClient)
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
.limit(1);
return userClients[0] ?? null;
};
export const getUserClientWithDetails = async (userId: number, clientId: number) => {
const userClient = await db
.selectFrom("user_client")
.innerJoin("client", "user_client.client_id", "client.id")
.selectAll()
.where("user_id", "=", userId)
.where("client_id", "=", clientId)
.limit(1)
.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;
const userClients = await db
.select()
.from(userClient)
.innerJoin(client, eq(userClient.clientId, client.id))
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
.limit(1);
return userClients[0] ?? null;
};
export const setUserClientStateToPending = async (userId: number, clientId: number) => {
await db
.updateTable("user_client")
.update(userClient)
.set({ state: "pending" })
.where("user_id", "=", userId)
.where("client_id", "=", clientId)
.where("state", "=", "challenging")
.execute();
.where(
and(
eq(userClient.userId, userId),
eq(userClient.clientId, clientId),
eq(userClient.state, "challenging"),
),
);
};
export const setUserClientStateToActive = async (userId: number, clientId: number) => {
await db
.updateTable("user_client")
.update(userClient)
.set({ state: "active" })
.where("user_id", "=", userId)
.where("client_id", "=", clientId)
.where("state", "=", "pending")
.execute();
.where(
and(
eq(userClient.userId, userId),
eq(userClient.clientId, clientId),
eq(userClient.state, "pending"),
),
);
};
export const registerUserClientChallenge = async (
@@ -162,36 +110,30 @@ export const registerUserClientChallenge = async (
allowedIp: string,
expiresAt: Date,
) => {
const { id } = await db
.insertInto("user_client_challenge")
.values({
user_id: userId,
client_id: clientId,
answer,
allowed_ip: allowedIp,
expires_at: expiresAt,
})
.returning("id")
.executeTakeFirstOrThrow();
return { id };
await db.insert(userClientChallenge).values({
userId,
clientId,
answer,
allowedIp,
expiresAt,
});
};
export const consumeUserClientChallenge = async (
challengeId: number,
userId: number,
ip: string,
) => {
const challenge = await db
.deleteFrom("user_client_challenge")
.where("id", "=", challengeId)
.where("user_id", "=", userId)
.where("allowed_ip", "=", ip)
.where("expires_at", ">", new Date())
.returning(["client_id", "answer"])
.executeTakeFirst();
return challenge ? { clientId: challenge.client_id, answer: challenge.answer } : null;
export const consumeUserClientChallenge = async (userId: number, answer: string, ip: string) => {
const challenges = await db
.delete(userClientChallenge)
.where(
and(
eq(userClientChallenge.userId, userId),
eq(userClientChallenge.answer, answer),
eq(userClientChallenge.allowedIp, ip),
gt(userClientChallenge.expiresAt, new Date()),
),
)
.returning({ clientId: userClientChallenge.clientId });
return challenges[0] ?? null;
};
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 =
// Category
| "Category not found"
// Challenge
| "Challenge already registered"
// Client
@@ -9,8 +7,6 @@ type IntegrityErrorMessages =
// File
| "Directory not found"
| "File not found"
| "File not found in category"
| "File already added to category"
| "Invalid DEK version"
// HSK
| "HSK already registered"

View File

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

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

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);
};

View File

@@ -1,19 +1,8 @@
import pg from "pg";
import { SqliteError } from "better-sqlite3";
import { and, or, eq } from "drizzle-orm";
import db from "./drizzle";
import { IntegrityError } from "./error";
import db from "./kysely";
import type { MekState } from "./schema";
interface Mek {
userId: number;
version: number;
state: MekState;
}
interface ClientMekWithDetails extends Mek {
clientId: number;
encMek: string;
encMekSig: string;
}
import { mek, mekLog, clientMek } from "./schema";
export const registerInitialMek = async (
userId: number,
@@ -21,67 +10,58 @@ export const registerInitialMek = async (
encMek: string,
encMekSig: string,
) => {
await db.transaction().execute(async (trx) => {
try {
await trx
.insertInto("master_encryption_key")
.values({
user_id: userId,
await db.transaction(
async (tx) => {
try {
await tx.insert(mek).values({
userId,
version: 1,
state: "active",
})
.execute();
await trx
.insertInto("client_master_encryption_key")
.values({
user_id: userId,
client_id: createdBy,
version: 1,
encrypted_key: encMek,
encrypted_key_signature: encMekSig,
})
.execute();
await trx
.insertInto("master_encryption_key_log")
.values({
user_id: userId,
master_encryption_key_version: 1,
});
await tx.insert(clientMek).values({
userId,
clientId: createdBy,
mekVersion: 1,
encMek,
encMekSig,
});
await tx.insert(mekLog).values({
userId,
mekVersion: 1,
timestamp: new Date(),
action: "create",
action_by: createdBy,
})
.execute();
} catch (e) {
if (e instanceof pg.DatabaseError && e.code === "23505") {
throw new IntegrityError("MEK already registered");
actionBy: createdBy,
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
throw new IntegrityError("MEK already registered");
}
throw e;
}
throw e;
}
});
},
{ behavior: "exclusive" },
);
};
export const getInitialMek = async (userId: number) => {
const meks = await db
.select()
.from(mek)
.where(and(eq(mek.userId, userId), eq(mek.version, 1)))
.limit(1);
return meks[0] ?? null;
};
export const getAllValidClientMeks = async (userId: number, clientId: number) => {
const clientMeks = await db
.selectFrom("client_master_encryption_key")
.innerJoin("master_encryption_key", (join) =>
join
.onRef("client_master_encryption_key.user_id", "=", "master_encryption_key.user_id")
.onRef("client_master_encryption_key.version", "=", "master_encryption_key.version"),
)
.selectAll()
.where("client_master_encryption_key.user_id", "=", userId)
.where("client_master_encryption_key.client_id", "=", clientId)
.where((eb) => eb.or([eb("state", "=", "active"), eb("state", "=", "retired")]))
.execute();
return clientMeks.map(
({ user_id, client_id, version, state, encrypted_key, encrypted_key_signature }) =>
({
userId: user_id,
version,
state: state as "active" | "retired",
clientId: client_id,
encMek: encrypted_key,
encMekSig: encrypted_key_signature,
}) satisfies ClientMekWithDetails,
);
return await db
.select()
.from(clientMek)
.innerJoin(mek, and(eq(clientMek.userId, mek.userId), eq(clientMek.mekVersion, mek.version)))
.where(
and(
eq(clientMek.userId, userId),
eq(clientMek.clientId, clientId),
or(eq(mek.state, "active"), eq(mek.state, "retired")),
),
);
};

View File

@@ -1,224 +0,0 @@
import { Kysely } from "kysely";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const up = async (db: Kysely<any>) => {
// user.ts
await db.schema
.createTable("user")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
.addColumn("email", "text", (col) => col.unique().notNull())
.addColumn("nickname", "text", (col) => col.notNull())
.addColumn("password", "text", (col) => col.notNull())
.execute();
// client.ts
await db.schema
.createTable("client")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
.addColumn("encryption_public_key", "text", (col) => col.unique().notNull())
.addColumn("signature_public_key", "text", (col) => col.unique().notNull())
.addUniqueConstraint("client_ak01", ["encryption_public_key", "signature_public_key"])
.execute();
await db.schema
.createTable("user_client")
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("client_id", "integer", (col) => col.references("client.id").notNull())
.addColumn("state", "text", (col) => col.notNull().defaultTo("challenging"))
.addPrimaryKeyConstraint("user_client_pk", ["user_id", "client_id"])
.execute();
await db.schema
.createTable("user_client_challenge")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("client_id", "integer", (col) => col.references("client.id").notNull())
.addColumn("answer", "text", (col) => col.unique().notNull())
.addColumn("allowed_ip", "text", (col) => col.notNull())
.addColumn("expires_at", "timestamp(3)", (col) => col.notNull())
.addForeignKeyConstraint(
"user_client_challenge_fk01",
["user_id", "client_id"],
"user_client",
["user_id", "client_id"],
)
.execute();
// session.ts
await db.schema
.createTable("session")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("client_id", "integer", (col) => col.references("client.id"))
.addColumn("created_at", "timestamp(3)", (col) => col.notNull())
.addColumn("last_used_at", "timestamp(3)", (col) => col.notNull())
.addColumn("last_used_by_ip", "text")
.addColumn("last_used_by_agent", "text")
.addUniqueConstraint("session_ak01", ["user_id", "client_id"])
.execute();
await db.schema
.createTable("session_upgrade_challenge")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
.addColumn("session_id", "text", (col) => col.references("session.id").unique().notNull())
.addColumn("client_id", "integer", (col) => col.references("client.id").notNull())
.addColumn("answer", "text", (col) => col.unique().notNull())
.addColumn("allowed_ip", "text", (col) => col.notNull())
.addColumn("expires_at", "timestamp(3)", (col) => col.notNull())
.execute();
// mek.ts
await db.schema
.createTable("master_encryption_key")
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("version", "integer", (col) => col.notNull())
.addColumn("state", "text", (col) => col.notNull())
.addPrimaryKeyConstraint("master_encryption_key_pk", ["user_id", "version"])
.execute();
await db.schema
.createTable("master_encryption_key_log")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("master_encryption_key_version", "integer", (col) => col.notNull())
.addColumn("timestamp", "timestamp(3)", (col) => col.notNull())
.addColumn("action", "text", (col) => col.notNull())
.addColumn("action_by", "integer", (col) => col.references("client.id"))
.addForeignKeyConstraint(
"master_encryption_key_log_fk01",
["user_id", "master_encryption_key_version"],
"master_encryption_key",
["user_id", "version"],
)
.execute();
await db.schema
.createTable("client_master_encryption_key")
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("client_id", "integer", (col) => col.references("client.id").notNull())
.addColumn("version", "integer", (col) => col.notNull())
.addColumn("encrypted_key", "text", (col) => col.notNull())
.addColumn("encrypted_key_signature", "text", (col) => col.notNull())
.addPrimaryKeyConstraint("client_master_encryption_key_pk", ["user_id", "client_id", "version"])
.addForeignKeyConstraint(
"client_master_encryption_key_fk01",
["user_id", "version"],
"master_encryption_key",
["user_id", "version"],
)
.execute();
// hsk.ts
await db.schema
.createTable("hmac_secret_key")
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("version", "integer", (col) => col.notNull())
.addColumn("state", "text", (col) => col.notNull())
.addColumn("master_encryption_key_version", "integer", (col) => col.notNull())
.addColumn("encrypted_key", "text", (col) => col.unique().notNull())
.addPrimaryKeyConstraint("hmac_secret_key_pk", ["user_id", "version"])
.addForeignKeyConstraint(
"hmac_secret_key_fk01",
["user_id", "master_encryption_key_version"],
"master_encryption_key",
["user_id", "version"],
)
.execute();
await db.schema
.createTable("hmac_secret_key_log")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("hmac_secret_key_version", "integer", (col) => col.notNull())
.addColumn("timestamp", "timestamp(3)", (col) => col.notNull())
.addColumn("action", "text", (col) => col.notNull())
.addColumn("action_by", "integer", (col) => col.references("client.id"))
.addForeignKeyConstraint(
"hmac_secret_key_log_fk01",
["user_id", "hmac_secret_key_version"],
"hmac_secret_key",
["user_id", "version"],
)
.execute();
// file.ts
await db.schema
.createTable("directory")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
.addColumn("parent_id", "integer", (col) => col.references("directory.id"))
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("master_encryption_key_version", "integer", (col) => col.notNull())
.addColumn("encrypted_data_encryption_key", "text", (col) => col.unique().notNull())
.addColumn("data_encryption_key_version", "timestamp(3)", (col) => col.notNull())
.addColumn("encrypted_name", "json", (col) => col.notNull())
.addForeignKeyConstraint(
"directory_fk01",
["user_id", "master_encryption_key_version"],
"master_encryption_key",
["user_id", "version"],
)
.execute();
await db.schema
.createTable("directory_log")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
.addColumn("directory_id", "integer", (col) =>
col.references("directory.id").onDelete("cascade").notNull(),
)
.addColumn("timestamp", "timestamp(3)", (col) => col.notNull())
.addColumn("action", "text", (col) => col.notNull())
.addColumn("new_name", "json")
.execute();
await db.schema
.createTable("file")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
.addColumn("parent_id", "integer", (col) => col.references("directory.id"))
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("path", "text", (col) => col.unique().notNull())
.addColumn("master_encryption_key_version", "integer", (col) => col.notNull())
.addColumn("encrypted_data_encryption_key", "text", (col) => col.unique().notNull())
.addColumn("data_encryption_key_version", "timestamp(3)", (col) => col.notNull())
.addColumn("hmac_secret_key_version", "integer")
.addColumn("content_hmac", "text")
.addColumn("content_type", "text", (col) => col.notNull())
.addColumn("encrypted_content_iv", "text", (col) => col.notNull())
.addColumn("encrypted_content_hash", "text", (col) => col.notNull())
.addColumn("encrypted_name", "json", (col) => col.notNull())
.addColumn("encrypted_created_at", "json")
.addColumn("encrypted_last_modified_at", "json", (col) => col.notNull())
.addForeignKeyConstraint(
"file_fk01",
["user_id", "master_encryption_key_version"],
"master_encryption_key",
["user_id", "version"],
)
.addForeignKeyConstraint(
"file_fk02",
["user_id", "hmac_secret_key_version"],
"hmac_secret_key",
["user_id", "version"],
)
.execute();
await db.schema
.createTable("file_log")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
.addColumn("file_id", "integer", (col) =>
col.references("file.id").onDelete("cascade").notNull(),
)
.addColumn("timestamp", "timestamp(3)", (col) => col.notNull())
.addColumn("action", "text", (col) => col.notNull())
.addColumn("new_name", "json")
.execute();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const down = async (db: Kysely<any>) => {
await db.schema.dropTable("file_log").execute();
await db.schema.dropTable("file").execute();
await db.schema.dropTable("directory_log").execute();
await db.schema.dropTable("directory").execute();
await db.schema.dropTable("hmac_secret_key_log").execute();
await db.schema.dropTable("hmac_secret_key").execute();
await db.schema.dropTable("client_master_encryption_key").execute();
await db.schema.dropTable("master_encryption_key_log").execute();
await db.schema.dropTable("master_encryption_key").execute();
await db.schema.dropTable("session_upgrade_challenge").execute();
await db.schema.dropTable("session").execute();
await db.schema.dropTable("user_client_challenge").execute();
await db.schema.dropTable("user_client").execute();
await db.schema.dropTable("client").execute();
await db.schema.dropTable("user").execute();
};

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