diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..b302e36
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,36 @@
+test-results
+node_modules
+
+# Output
+.output
+.vercel
+/.svelte-kit
+/build
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# jetbrains setting folder
+.idea/
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
+
+# SQLite
+*.db
+
+# Docker
+Dockerfile
+Dockerfile.migrate
+docker-compose.yaml
+
+# Deployment
+deploy.sh
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..d59bf33
--- /dev/null
+++ b/.env.example
@@ -0,0 +1 @@
+DATABASE_URL=local.db
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7b20898
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,28 @@
+test-results
+node_modules
+
+# Output
+.output
+.vercel
+/.svelte-kit
+/build
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# jetbrains setting folder
+.idea/
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
+
+# SQLite
+*.db
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..b6f27f1
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..61d43f0
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,44 @@
+# Adjust BUN_VERSION as desired
+ARG BUN_VERSION=1.1.36
+FROM oven/bun:${BUN_VERSION}-slim AS base
+
+# Base image
+WORKDIR /app
+ENV NODE_ENV="production"
+
+
+FROM base AS build
+
+# Install packages needed to build node modules
+RUN apt-get update -qq && \
+ apt-get install -y --no-install-recommends build-essential pkg-config python-is-python3
+
+# Install node modules
+COPY --link package.json bun.lockb ./
+RUN bun install
+
+# Copy application code
+COPY --link . .
+
+# Build application
+RUN bunx svelte-kit sync
+RUN DATABASE_URL=blabla bun run build
+
+## Remove development dependencies
+RUN rm -rf node_modules && \
+ bun install --production
+
+
+# Final stage for app image
+FROM base AS app
+
+# Copy built application
+COPY --from=build /app/node_modules /app/node_modules
+COPY --from=build /app/build /app/build
+
+ENV PORT=4321
+ENV HOST=0.0.0.0
+
+# Start the server by default, this can be overwritten at runtime
+EXPOSE 4321
+CMD ["bun", "./build"]
diff --git a/Dockerfile.migrate b/Dockerfile.migrate
new file mode 100644
index 0000000..bcc48d9
--- /dev/null
+++ b/Dockerfile.migrate
@@ -0,0 +1,28 @@
+# Adjust BUN_VERSION as desired
+ARG BUN_VERSION=1.1.36
+FROM node:20-slim AS base
+
+# Base image
+WORKDIR /app
+ENV NODE_ENV="production"
+
+# Install packages needed to build node modules
+RUN apt-get update -qq && \
+ apt-get install --no-install-recommends -y build-essential pkg-config python-is-python3
+
+# Install bun
+RUN npm install -g bun@${BUN_VERSION}
+
+
+FROM base AS build
+
+# Install node modules
+COPY --link package.json bun.lockb ./
+RUN bun install
+
+# Copy application code
+COPY --link drizzle/ ./drizzle/
+COPY --link drizzle.config.ts ./
+
+# Migrate the database
+CMD ["bun", "run", "db", "migrate"]
diff --git a/README.md b/README.md
index b446e79..32f198f 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,50 @@
-# Cybersecurity-projects-2024
-Projects of the Cybersecurity course 2024/25, University of Bologna
+# sv
+
+## Use cases
+
+- [x] Register
+ - Username
+ - Password
+ -> Returns session token and user database chunk
+- [x] Login
+ - Username
+ - Password
+ -> Returns session token and user database chunk
+- [x] Get current session
+- [x] Change password
+- [ ] Upload file
+ 1. Select file
+ 2. Chunk file using fast-cdc
+ 3. Encrypt file
+ 4. Upload chunks
+ 5. Update local database
+ 6. Chunk local database using fast-cdc
+ 7. Encrypt local database
+ 8. Upload chunks
+- [ ] Download file
+ 1. Download chunks
+ 2. Join chunks and restore blob
+ 3. Decrypt blob
+- [ ] Delete file
+ 1. Delete chunks
+ 2. Update local database
+ 3. Encrypt local database
+ 4. Chunk local database using fast-cdc
+ 5. Upload chunks
+
+## Thoughts
+
+- Use `zlib` to compress the local database
+
+## Current issues
+
+- Registration leaks the information whether a username exists or not
+- Does the second request of the login procedure properly prevent timing attacks?
+- Is my own implementation of signing the session id secure?
+- Is sending the signed session id as a cookie secure?
+- What if an upload takes longer than 24 hours when the `upload` record is deleted?
+- We have to take all data of the user into consideration when calculating their storage usage because evil people could
+ just hide their data in the database
+ - Take the 40 bytes overhead into consideration
+ - Encrypted file attributes
+ - Any keys the user uploads
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..5dd6df2
Binary files /dev/null and b/bun.lockb differ
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..38a00f6
--- /dev/null
+++ b/components.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://next.shadcn-svelte.com/schema.json",
+ "style": "default",
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "src/app.css",
+ "baseColor": "stone"
+ },
+ "aliases": {
+ "components": "$lib/components",
+ "utils": "$lib/utils/components",
+ "ui": "$lib/components/ui",
+ "hooks": "$lib/hooks"
+ },
+ "typescript": true,
+ "registry": "https://next.shadcn-svelte.com/registry"
+}
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..b2f141a
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# This will build and restart to the production container.
+set -e
+
+server=stefan
+app_root=/home/amadeus/http/csp
+app_storage=/home/amadeus/storage/csp
+app_url=https://csp.deer13.dev
+
+echo -e '\033[1mDeploying...\033[0m'
+echo $app_url
+echo
+
+rsync -vhra \
+ . \
+ $server:$app_root \
+ --include='**.gitignore' \
+ --exclude='/.git' \
+ --filter=':- .gitignore' \
+ --delete-after
+
+ssh $server << EOF
+ set -e
+
+ cd $app_root
+
+ # Build images
+ docker build -f Dockerfile -t llamadeus/csp .
+ docker build -f Dockerfile.migrate -t llamadeus/csp-migrate .
+
+ # Stop container
+ docker compose stop
+
+ # Migrate
+ docker run --rm -e DATABASE_URL="/app/db/prod.db" -v "/home/amadeus/storage/csp/db":"/app/db" llamadeus/csp-migrate
+
+ # Restart container
+ docker compose up -d
+
+ # Clean up cached resources (anything older than 7 days)
+ yes | docker image prune -a --filter "until=168h"
+ yes | docker builder prune -a --filter "until=168h"
+EOF
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 0000000..9dc5c26
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,18 @@
+services:
+ web:
+ image: llamadeus/csp
+ restart: unless-stopped
+ networks:
+ - caddy
+ labels:
+ caddy: csp.deer13.dev
+ caddy.reverse_proxy: "{{upstreams 4321}}"
+ volumes:
+ - /home/amadeus/storage/csp/db:/app/db
+ environment:
+ - PUBLIC_APP_URL=https://csp.deer13.dev
+ - DATABASE_URL=/app/db/prod.db
+
+networks:
+ caddy:
+ external: true
diff --git a/drizzle.config.ts b/drizzle.config.ts
new file mode 100644
index 0000000..e8e0755
--- /dev/null
+++ b/drizzle.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from "drizzle-kit";
+
+
+if (! process.env.DATABASE_URL) {
+ throw new Error("DATABASE_URL is not set");
+}
+
+export default defineConfig({
+ schema: "./src/lib/server/db/schema.ts",
+
+ dbCredentials: {
+ url: process.env.DATABASE_URL,
+ },
+
+ verbose: true,
+ strict: true,
+ dialect: "sqlite",
+});
diff --git a/drizzle/0000_the_friendly_executioner.sql b/drizzle/0000_the_friendly_executioner.sql
new file mode 100644
index 0000000..0e61f84
--- /dev/null
+++ b/drizzle/0000_the_friendly_executioner.sql
@@ -0,0 +1,35 @@
+CREATE TABLE `chunks` (
+ `id` text PRIMARY KEY NOT NULL,
+ `user_id` text NOT NULL,
+ `blob` blob NOT NULL,
+ `created_at` integer NOT NULL,
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
+);
+--> statement-breakpoint
+CREATE TABLE `files` (
+ `id` text PRIMARY KEY NOT NULL,
+ `user_id` text NOT NULL,
+ `key` text NOT NULL,
+ `cmac` text NOT NULL,
+ `attributes` text NOT NULL,
+ `created_at` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `sessions` (
+ `id` text PRIMARY KEY NOT NULL,
+ `user_id` text NOT NULL,
+ `session_id` text NOT NULL,
+ `created_at` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `sessions_session_id_unique` ON `sessions` (`session_id`);--> statement-breakpoint
+CREATE TABLE `users` (
+ `id` text PRIMARY KEY NOT NULL,
+ `username` text NOT NULL,
+ `crv` text NOT NULL,
+ `hak` text NOT NULL,
+ `key` text NOT NULL,
+ `created_at` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
\ No newline at end of file
diff --git a/drizzle/0001_watery_eternals.sql b/drizzle/0001_watery_eternals.sql
new file mode 100644
index 0000000..c99cb28
--- /dev/null
+++ b/drizzle/0001_watery_eternals.sql
@@ -0,0 +1,34 @@
+CREATE TABLE `slot_chunks` (
+ `slot_id` text NOT NULL,
+ `chunk_id` text NOT NULL,
+ PRIMARY KEY(`slot_id`, `chunk_id`),
+ FOREIGN KEY (`slot_id`) REFERENCES `slots`(`id`) ON UPDATE no action ON DELETE no action,
+ FOREIGN KEY (`chunk_id`) REFERENCES `chunks`(`id`) ON UPDATE no action ON DELETE no action
+);
+--> statement-breakpoint
+CREATE TABLE `slots` (
+ `id` text PRIMARY KEY NOT NULL,
+ `vault_id` text NOT NULL,
+ `max_size` integer NOT NULL,
+ `attributes` text NOT NULL,
+ `file_x25519_public_key` text,
+ `file_id` text,
+ `created_at` integer NOT NULL,
+ FOREIGN KEY (`vault_id`) REFERENCES `vaults`(`id`) ON UPDATE no action ON DELETE no action,
+ FOREIGN KEY (`file_id`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE no action
+);
+--> statement-breakpoint
+CREATE TABLE `vaults` (
+ `id` text PRIMARY KEY NOT NULL,
+ `user_id` text NOT NULL,
+ `token` text NOT NULL,
+ `key` text NOT NULL,
+ `attributes` text NOT NULL,
+ `auth_ed25519_private_key` text NOT NULL,
+ `auth_ed25519_public_key` text NOT NULL,
+ `created_at` integer NOT NULL,
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
+);
+--> statement-breakpoint
+ALTER TABLE `users` ADD `x25519_private_key` text;--> statement-breakpoint
+ALTER TABLE `users` ADD `x25519_public_key` text;
\ No newline at end of file
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
new file mode 100644
index 0000000..921fe21
--- /dev/null
+++ b/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,228 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "6a564f0e-d7c2-4c7a-80db-63f3de227fc0",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "tables": {
+ "chunks": {
+ "name": "chunks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "blob": {
+ "name": "blob",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "chunks_user_id_users_id_fk": {
+ "name": "chunks_user_id_users_id_fk",
+ "tableFrom": "chunks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "files": {
+ "name": "files",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cmac": {
+ "name": "cmac",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attributes": {
+ "name": "attributes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "sessions_session_id_unique": {
+ "name": "sessions_session_id_unique",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "crv": {
+ "name": "crv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "hak": {
+ "name": "hak",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "columns": [
+ "username"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..b207ad6
--- /dev/null
+++ b/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,467 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "603f72a4-fd10-4d55-96fd-bb80b641dbc5",
+ "prevId": "6a564f0e-d7c2-4c7a-80db-63f3de227fc0",
+ "tables": {
+ "chunks": {
+ "name": "chunks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "blob": {
+ "name": "blob",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "chunks_user_id_users_id_fk": {
+ "name": "chunks_user_id_users_id_fk",
+ "tableFrom": "chunks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "files": {
+ "name": "files",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cmac": {
+ "name": "cmac",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attributes": {
+ "name": "attributes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "sessions_session_id_unique": {
+ "name": "sessions_session_id_unique",
+ "columns": [
+ "session_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "slot_chunks": {
+ "name": "slot_chunks",
+ "columns": {
+ "slot_id": {
+ "name": "slot_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "chunk_id": {
+ "name": "chunk_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "slot_chunks_slot_id_slots_id_fk": {
+ "name": "slot_chunks_slot_id_slots_id_fk",
+ "tableFrom": "slot_chunks",
+ "tableTo": "slots",
+ "columnsFrom": [
+ "slot_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "slot_chunks_chunk_id_chunks_id_fk": {
+ "name": "slot_chunks_chunk_id_chunks_id_fk",
+ "tableFrom": "slot_chunks",
+ "tableTo": "chunks",
+ "columnsFrom": [
+ "chunk_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "slot_chunks_slot_id_chunk_id_pk": {
+ "columns": [
+ "slot_id",
+ "chunk_id"
+ ],
+ "name": "slot_chunks_slot_id_chunk_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "slots": {
+ "name": "slots",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "vault_id": {
+ "name": "vault_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "max_size": {
+ "name": "max_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attributes": {
+ "name": "attributes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_x25519_public_key": {
+ "name": "file_x25519_public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_id": {
+ "name": "file_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "slots_vault_id_vaults_id_fk": {
+ "name": "slots_vault_id_vaults_id_fk",
+ "tableFrom": "slots",
+ "tableTo": "vaults",
+ "columnsFrom": [
+ "vault_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "slots_file_id_files_id_fk": {
+ "name": "slots_file_id_files_id_fk",
+ "tableFrom": "slots",
+ "tableTo": "files",
+ "columnsFrom": [
+ "file_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "crv": {
+ "name": "crv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "hak": {
+ "name": "hak",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x25519_private_key": {
+ "name": "x25519_private_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "x25519_public_key": {
+ "name": "x25519_public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "columns": [
+ "username"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "vaults": {
+ "name": "vaults",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attributes": {
+ "name": "attributes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_ed25519_private_key": {
+ "name": "auth_ed25519_private_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_ed25519_public_key": {
+ "name": "auth_ed25519_public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "vaults_user_id_users_id_fk": {
+ "name": "vaults_user_id_users_id_fk",
+ "tableFrom": "vaults",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
new file mode 100644
index 0000000..3c5bf51
--- /dev/null
+++ b/drizzle/meta/_journal.json
@@ -0,0 +1,20 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "6",
+ "when": 1732462040141,
+ "tag": "0000_the_friendly_executioner",
+ "breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "6",
+ "when": 1734459805195,
+ "tag": "0001_watery_eternals",
+ "breakpoints": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/e2e/demo.test.ts b/e2e/demo.test.ts
new file mode 100644
index 0000000..9985ce1
--- /dev/null
+++ b/e2e/demo.test.ts
@@ -0,0 +1,6 @@
+import { expect, test } from '@playwright/test';
+
+test('home page has expected h1', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator('h1')).toBeVisible();
+});
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..1a704f4
--- /dev/null
+++ b/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "cs-project",
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "bunx --bun vite dev",
+ "build": "bunx --bun vite build",
+ "preview": "bunx --bun vite preview",
+ "start": "bun ./build",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "db": "tsx node_modules/drizzle-kit/bin.cjs",
+ "test:e2e": "playwright test",
+ "test": "npm run test:e2e && npm run test:unit -- --run",
+ "test:unit": "vitest",
+ "shadcn": "bunx shadcn-svelte@next"
+ },
+ "dependencies": {
+ "@noble/ciphers": "^1.1.0",
+ "@noble/curves": "^1.7.0",
+ "@noble/hashes": "^1.6.0",
+ "@trpc/client": "^10.45.2",
+ "@trpc/server": "^10.45.2",
+ "better-sqlite3": "^11.5.0",
+ "bits-ui": "^1.0.0-next.72",
+ "clsx": "^2.1.1",
+ "dayjs": "^1.11.13",
+ "drizzle-orm": "^0.36.4",
+ "lucide-svelte": "^0.468.0",
+ "nanoid": "^5.0.8",
+ "tailwind-variants": "^0.3.0",
+ "trpc-sveltekit": "^3.6.2",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.49.0",
+ "@sveltejs/adapter-node": "^5.2.10",
+ "@sveltejs/kit": "^2.12.0",
+ "@sveltejs/vite-plugin-svelte": "^4.0.3",
+ "@types/better-sqlite3": "^7.6.12",
+ "@types/bun": "^1.1.13",
+ "autoprefixer": "^10.4.20",
+ "drizzle-kit": "^0.28.1",
+ "svelte": "^5.14.0",
+ "svelte-adapter-bun": "^0.5.2",
+ "svelte-check": "^4.1.1",
+ "svelte-sonner": "^0.3.28",
+ "tailwind-merge": "^2.5.4",
+ "tailwindcss": "^3.4.15",
+ "tailwindcss-animate": "^1.0.7",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.2",
+ "vite": "^5.4.11",
+ "vitest": "^2.1.5"
+ }
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..0cf2600
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from '@playwright/test';
+
+
+export default defineConfig({
+ webServer: {
+ command: 'npm run build && npm run preview',
+ port: 4173,
+ },
+
+ testDir: 'e2e',
+});
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/presentation/presentation.pdf b/presentation/presentation.pdf
new file mode 100644
index 0000000..54747dd
Binary files /dev/null and b/presentation/presentation.pdf differ
diff --git a/report/report.pdf b/report/report.pdf
new file mode 100644
index 0000000..0596a9f
Binary files /dev/null and b/report/report.pdf differ
diff --git a/src/app.d.ts b/src/app.d.ts
new file mode 100644
index 0000000..1ec863c
--- /dev/null
+++ b/src/app.d.ts
@@ -0,0 +1,15 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+// for information about these interfaces
+
+
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/src/app.html b/src/app.html
new file mode 100644
index 0000000..9f10bf1
--- /dev/null
+++ b/src/app.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/src/demo.spec.ts b/src/demo.spec.ts
new file mode 100644
index 0000000..489f398
--- /dev/null
+++ b/src/demo.spec.ts
@@ -0,0 +1,8 @@
+import { describe, expect, it } from "vitest";
+
+
+describe("sum test", () => {
+ it("adds 1 + 2 to equal 3", () => {
+ expect(1 + 2).toBe(3);
+ });
+});
diff --git a/src/features/login/index.ts b/src/features/login/index.ts
new file mode 100644
index 0000000..5932299
--- /dev/null
+++ b/src/features/login/index.ts
@@ -0,0 +1,62 @@
+import { trpc } from "$lib/trpc/client";
+import { decrypt, encrypt } from "$lib/tulip";
+import { generateDerivedKeys } from "$lib/tulip/auth";
+import { decodeBase64Url, encodeBase64Url } from "$lib/utils/base64url";
+import { utf8ToBytes } from "@noble/ciphers/utils";
+import { x25519 } from "@noble/curves/ed25519";
+
+
+interface LoginInput {
+ username: string;
+ password: string;
+}
+
+interface LoginResult {
+ username: string;
+ masterKey: Uint8Array;
+ x25519PrivateKey: Uint8Array;
+ x25519PublicKey: Uint8Array;
+}
+
+export async function login(values: LoginInput): Promise {
+ const crvResponse = await trpc().crv.query({
+ u: values.username,
+ });
+
+ const [derivedEncryptionKey, derivedAuthenticationKey] = generateDerivedKeys(
+ utf8ToBytes(values.password),
+ decodeBase64Url(crvResponse.salt),
+ );
+
+ if (derivedEncryptionKey == null || derivedAuthenticationKey == null) {
+ throw new Error("Could not generate derived keys");
+ }
+
+ const loginResponse = await trpc().login.mutate({
+ u: values.username,
+ ak: encodeBase64Url(derivedAuthenticationKey),
+ });
+
+ const [decryptedMasterKey] = decrypt(derivedEncryptionKey, decodeBase64Url(loginResponse.key));
+ let x25519PrivateKey = loginResponse.x25519PrivateKey !== null ? decodeBase64Url(loginResponse.x25519PrivateKey) : null;
+ let x25519PublicKey = loginResponse.x25519PublicKey !== null ? decodeBase64Url(loginResponse.x25519PublicKey) : null;
+
+ if (x25519PrivateKey === null || x25519PublicKey === null) {
+ x25519PrivateKey = x25519.utils.randomPrivateKey();
+ x25519PublicKey = x25519.getPublicKey(x25519PrivateKey);
+
+ const encryptedX25519PrivateKey = encrypt(decryptedMasterKey, x25519PrivateKey);
+
+ await trpc().updateX25519.mutate({
+ x25519PrivateKey: encodeBase64Url(encryptedX25519PrivateKey),
+ x25519PublicKey: encodeBase64Url(x25519PublicKey),
+ });
+ }
+
+ return {
+ username: loginResponse.u,
+ masterKey: decryptedMasterKey,
+ x25519PrivateKey,
+ x25519PublicKey,
+ };
+}
diff --git a/src/features/register/index.ts b/src/features/register/index.ts
new file mode 100644
index 0000000..41f6e53
--- /dev/null
+++ b/src/features/register/index.ts
@@ -0,0 +1,52 @@
+import { trpc } from "$lib/trpc/client";
+import { encrypt, randomKey } from "$lib/tulip";
+import { generateDerivedKeys, generateHashedAuthenticationKey, generateSalt, randomClientValue } from "$lib/tulip/auth";
+import { encodeBase64Url } from "$lib/utils/base64url";
+import { utf8ToBytes } from "@noble/ciphers/utils";
+import { x25519 } from "@noble/curves/ed25519";
+
+
+interface CreateAccountInput {
+ username: string;
+ password: string;
+}
+
+interface CreateAccountResult {
+ username: string;
+ masterKey: Uint8Array;
+ salt: Uint8Array;
+ clientRandomValue: Uint8Array;
+ encryptedMasterKey: Uint8Array;
+ hashedAuthenticationKey: Uint8Array;
+}
+
+export async function createAccount(values: CreateAccountInput): Promise {
+ const masterKey = randomKey();
+ const clientRandomValue = randomClientValue();
+ const salt = generateSalt(clientRandomValue);
+ const [derivedEncryptionKey, derivedAuthenticationKey] = generateDerivedKeys(utf8ToBytes(values.password), salt);
+ const encryptedMasterKey = encrypt(derivedEncryptionKey, masterKey);
+ const hashedAuthenticationKey = generateHashedAuthenticationKey(derivedAuthenticationKey);
+
+ const x25519PrivateKey = x25519.utils.randomPrivateKey();
+ const x25519PublicKey = x25519.getPublicKey(x25519PrivateKey);
+ const encryptedX25519PrivateKey = encrypt(masterKey, x25519PrivateKey);
+
+ await trpc().register.mutate({
+ u: values.username,
+ crv: encodeBase64Url(clientRandomValue),
+ key: encodeBase64Url(encryptedMasterKey),
+ hak: encodeBase64Url(hashedAuthenticationKey),
+ x25519PrivateKey: encodeBase64Url(encryptedX25519PrivateKey),
+ x25519PublicKey: encodeBase64Url(x25519PublicKey),
+ });
+
+ return {
+ username: values.username,
+ masterKey,
+ salt,
+ clientRandomValue,
+ encryptedMasterKey,
+ hashedAuthenticationKey,
+ };
+}
diff --git a/src/features/storage/download.ts b/src/features/storage/download.ts
new file mode 100644
index 0000000..b81cb5b
--- /dev/null
+++ b/src/features/storage/download.ts
@@ -0,0 +1,37 @@
+import type { RouterOutput } from "$lib/trpc/client";
+import { decrypt } from "$lib/tulip";
+import { type FileAttributes, getDecryptedFileAttributes, getDecryptedFileKey } from "~/features/storage/file";
+
+
+type RemoteFile = RouterOutput["files"]["files"][number];
+
+/**
+ * Downloads and decrypts the chunks of a file.
+ *
+ * @param masterKey
+ * @param file
+ * @param attributes
+ */
+export async function* download(
+ masterKey: Uint8Array,
+ file: RemoteFile,
+ attributes?: FileAttributes,
+): AsyncGenerator {
+ const fileKey = getDecryptedFileKey(masterKey, file);
+
+ if (typeof attributes == "undefined") {
+ attributes = getDecryptedFileAttributes(masterKey, file);
+ }
+
+ for (const chunkId of attributes.chunks) {
+ const response = await fetch(`/api/dl/${chunkId}`);
+ if (! response.ok) {
+ throw new Error("Failed to download chunk");
+ }
+
+ const arrayBuffer = await response.arrayBuffer();
+ const [decrypted] = decrypt(fileKey, new Uint8Array(arrayBuffer));
+
+ yield decrypted;
+ }
+}
diff --git a/src/features/storage/file.ts b/src/features/storage/file.ts
new file mode 100644
index 0000000..971bf0e
--- /dev/null
+++ b/src/features/storage/file.ts
@@ -0,0 +1,49 @@
+import type { RouterOutput } from "$lib/trpc/client";
+import { decrypt } from "$lib/tulip";
+import { unobfuscateFileKey } from "$lib/tulip/files";
+import { decodeBase64Url } from "$lib/utils/base64url";
+import { bytesToUtf8 } from "@noble/ciphers/utils";
+import { z } from "zod";
+
+
+type RemoteFile = RouterOutput["files"]["files"][number];
+
+const fileAttributesSchema = z.object({
+ name: z.string(),
+ type: z.string(),
+ size: z.number(),
+ chunks: z.array(z.string()),
+});
+
+export type FileAttributes = z.infer;
+export type ChunkEntry = FileAttributes["chunks"][number];
+
+/**
+ * Returns the decrypted file key for a file.
+ *
+ * @param masterKey
+ * @param file
+ */
+export function getDecryptedFileKey(masterKey: Uint8Array, file: Pick): Uint8Array {
+ const encryptedFileKey = decodeBase64Url(file.key);
+ const [obfuscatedFileKey, nonce] = decrypt(masterKey, encryptedFileKey);
+
+ return unobfuscateFileKey(obfuscatedFileKey, nonce, decodeBase64Url(file.cmac));
+}
+
+/**
+ * Returns the decrypted file attributes for a file.
+ *
+ * @param masterKey
+ * @param file
+ */
+export function getDecryptedFileAttributes(
+ masterKey: Uint8Array,
+ file: Pick,
+): FileAttributes {
+ const fileKey = getDecryptedFileKey(masterKey, file);
+ const rawAttributes = decodeBase64Url(file.attributes);
+ const [decryptedAttributes] = decrypt(fileKey, rawAttributes);
+
+ return JSON.parse(bytesToUtf8(decryptedAttributes));
+}
diff --git a/src/features/storage/index.ts b/src/features/storage/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/features/storage/upload.ts b/src/features/storage/upload.ts
new file mode 100644
index 0000000..8d1e5f5
--- /dev/null
+++ b/src/features/storage/upload.ts
@@ -0,0 +1,95 @@
+import { fastCDC2 } from "$lib/fastcdc";
+import { trpc } from "$lib/trpc/client";
+import { encrypt, getAuthTag, randomKey, randomNonce } from "$lib/tulip";
+import { obfuscateFileKey, updateCondensedMac } from "$lib/tulip/files";
+import { encodeBase64Url } from "$lib/utils/base64url";
+import { utf8ToBytes } from "@noble/ciphers/utils";
+import type { FileAttributes } from "~/features/storage/file";
+
+
+interface VaultInfo {
+ ticket: Uint8Array;
+ x25519PublicKey: Uint8Array
+}
+
+/**
+ * Chunks the given file using FastCDC, encrypts them, and stores the chunks on the server.
+ *
+ * @param masterKey
+ * @param file
+ * @param vaultInfo
+ */
+export async function upload(masterKey: Uint8Array, file: File, vaultInfo?: VaultInfo) {
+ const chunks = fastCDC2(file.stream().getReader(), {
+ minSize: 1024,
+ maxSize: 8 * 1024 * 1024,
+ averageSize: 4 * 1024 * 1024,
+ });
+
+ const fileKey = randomKey();
+ const baseNonce = randomNonce();
+ const chunkIds: string[] = [];
+
+ let condensedMac = new Uint8Array(16);
+
+ for await (const chunk of chunks) {
+ const encryptedChunk = encrypt(fileKey, chunk.data);
+ const formData = new FormData();
+ formData.append("blob", new Blob([encryptedChunk]));
+
+ if (typeof vaultInfo != "undefined") {
+ formData.append("ticket", new Blob([vaultInfo.ticket]));
+ }
+
+ // Upload the chunk to the server
+ const response = await fetch("/api/ul", {
+ method: "POST",
+ headers: {
+ "x-application": "sveltekit",
+ },
+ body: formData,
+ });
+ if (! response.ok) {
+ const { message } = await response.json();
+
+ throw new Error(message);
+ }
+
+ const { id: chunkId } = await response.json();
+
+ condensedMac = updateCondensedMac(condensedMac, getAuthTag(encryptedChunk));
+ chunkIds.push(chunkId);
+ }
+
+ // Obfuscate the file key using the base nonce and the condensed MAC
+ const obfuscatedFileKey = obfuscateFileKey(fileKey, baseNonce, condensedMac);
+ // Encrypt the file key using the master key
+ const encryptedFileKey = encrypt(masterKey, obfuscatedFileKey, baseNonce);
+
+ // Finalize the upload and store the file key and the base nonce
+ const fileType = file.type.length > 0 ? file.type : "application/octet-stream";
+ const attributes = JSON.stringify({
+ name: file.name,
+ type: fileType,
+ size: file.size,
+ chunks: chunkIds,
+ } satisfies FileAttributes);
+ const encryptedAttributes = encrypt(fileKey, utf8ToBytes(attributes));
+
+ if (typeof vaultInfo == "undefined") {
+ await trpc().upload.mutate({
+ key: encodeBase64Url(encryptedFileKey),
+ cmac: encodeBase64Url(condensedMac),
+ attributes: encodeBase64Url(encryptedAttributes),
+ });
+ }
+ else {
+ await trpc().uploadWithTicket.mutate({
+ ticket: encodeBase64Url(vaultInfo.ticket),
+ x25519PublicKey: encodeBase64Url(vaultInfo.x25519PublicKey),
+ key: encodeBase64Url(encryptedFileKey),
+ cmac: encodeBase64Url(condensedMac),
+ attributes: encodeBase64Url(encryptedAttributes),
+ });
+ }
+}
diff --git a/src/features/vaults/components/CreateVaultButton.svelte b/src/features/vaults/components/CreateVaultButton.svelte
new file mode 100644
index 0000000..9f47449
--- /dev/null
+++ b/src/features/vaults/components/CreateVaultButton.svelte
@@ -0,0 +1,157 @@
+
+
+
+
diff --git a/src/features/vaults/create.ts b/src/features/vaults/create.ts
new file mode 100644
index 0000000..61cd4a9
--- /dev/null
+++ b/src/features/vaults/create.ts
@@ -0,0 +1,62 @@
+import { trpc } from "$lib/trpc/client";
+import { encrypt, randomKey } from "$lib/tulip";
+import { encodeBase64Url } from "$lib/utils/base64url";
+import { utf8ToBytes } from "@noble/ciphers/utils";
+import { ed25519 } from "@noble/curves/ed25519";
+
+
+interface CreateVaultInput {
+ name: string;
+ slots: Slot[];
+}
+
+interface Slot {
+ name: string;
+ maxSize: number;
+}
+
+interface CreateVaultResult {
+ id: string;
+ token: string;
+ vaultKey: Uint8Array;
+}
+
+export async function createVault(masterKey: Uint8Array, values: CreateVaultInput): Promise {
+ const attributes = JSON.stringify({
+ name: values.name,
+ });
+ const vaultKey = randomKey();
+ const encryptedVaultKey = encrypt(masterKey, vaultKey);
+ const encryptedAttributes = encrypt(vaultKey, utf8ToBytes(attributes));
+ const slots = values.slots.map((slot) => {
+ const slotAttributes = JSON.stringify({
+ name: slot.name,
+ });
+
+ return ({
+ attributes: encrypt(vaultKey, utf8ToBytes(slotAttributes)),
+ maxSize: slot.maxSize,
+ });
+ });
+
+ const authEd25519PrivateKey = ed25519.utils.randomPrivateKey();
+ const authEd25519PublicKey = ed25519.getPublicKey(authEd25519PrivateKey);
+ const encryptedAuthEd25519PrivateKey = encrypt(vaultKey, authEd25519PrivateKey);
+
+ const vault = await trpc().createVault.mutate({
+ key: encodeBase64Url(encryptedVaultKey),
+ attributes: encodeBase64Url(encryptedAttributes),
+ authEd25519PrivateKey: encodeBase64Url(encryptedAuthEd25519PrivateKey),
+ authEd25519PublicKey: encodeBase64Url(authEd25519PublicKey),
+ slots: slots.map((slot) => ({
+ attributes: encodeBase64Url(slot.attributes),
+ maxSize: slot.maxSize,
+ })),
+ });
+
+ return {
+ id: vault.id,
+ token: vault.token,
+ vaultKey,
+ };
+}
diff --git a/src/hooks.client.ts b/src/hooks.client.ts
new file mode 100644
index 0000000..3ee953f
--- /dev/null
+++ b/src/hooks.client.ts
@@ -0,0 +1,11 @@
+import { globals } from "$lib/globals";
+import { openDB } from "$lib/keyval";
+import dayjs from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
+
+
+dayjs.extend(relativeTime);
+
+openDB().then((store) => {
+ globals.setKeyValStore(store);
+});
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
new file mode 100644
index 0000000..9b9b887
--- /dev/null
+++ b/src/hooks.server.ts
@@ -0,0 +1,7 @@
+import { createContext } from "$lib/trpc/context";
+import { router } from "$lib/trpc/router";
+import type { Handle } from "@sveltejs/kit";
+import { createTRPCHandle } from "trpc-sveltekit";
+
+
+export const handle: Handle = createTRPCHandle({ router, createContext });
diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts
new file mode 100644
index 0000000..39021ce
--- /dev/null
+++ b/src/lib/auth/index.ts
@@ -0,0 +1,8 @@
+import { AuthState } from "$lib/auth/state.svelte";
+
+
+export const auth = new AuthState();
+
+export function onReady(callback: () => void) {
+ auth.ready.then(callback);
+}
diff --git a/src/lib/auth/state.svelte.ts b/src/lib/auth/state.svelte.ts
new file mode 100644
index 0000000..ab92dde
--- /dev/null
+++ b/src/lib/auth/state.svelte.ts
@@ -0,0 +1,40 @@
+function deferred(): [promise: Promise, resolve: (value: T) => void] {
+ let resolve: ((value: T) => void) | undefined;
+ const promise = new Promise((res) => {
+ resolve = res;
+ });
+
+ if (typeof resolve == "undefined") {
+ throw new Error("Deferred not initialized");
+ }
+
+ return [promise, resolve];
+}
+
+export interface Auth {
+ username: string;
+ masterKey: Uint8Array;
+ x25519PrivateKey: Uint8Array;
+ x25519PublicKey: Uint8Array;
+}
+
+export class AuthState {
+ #state = $state(null);
+ #ready = deferred();
+
+ get state(): Auth | null {
+ return this.#state;
+ }
+
+ get ready(): Promise {
+ return this.#ready[0];
+ }
+
+ setLogin(auth: Auth | null) {
+ this.#state = auth;
+ }
+
+ setReady() {
+ this.#ready[1]();
+ }
+}
diff --git a/src/lib/components/Dropzone.svelte b/src/lib/components/Dropzone.svelte
new file mode 100644
index 0000000..c48fc93
--- /dev/null
+++ b/src/lib/components/Dropzone.svelte
@@ -0,0 +1,76 @@
+
+
+ inputElement?.click()}
+ ondrop={handleDrop}
+ ondragover={handleDragOver}
+ ondragleave={handleDragLeave}
+ onkeydown={(event) => event.key === " " && inputElement?.click()}
+>
+
+
+
+
+
+ Upload a file
+
+
or drag and drop
+
+
Any file, any size
+
+
+
+
diff --git a/src/lib/components/LogoutButton.svelte b/src/lib/components/LogoutButton.svelte
new file mode 100644
index 0000000..2071063
--- /dev/null
+++ b/src/lib/components/LogoutButton.svelte
@@ -0,0 +1,27 @@
+
+
+
diff --git a/src/lib/components/PurgeButton.svelte b/src/lib/components/PurgeButton.svelte
new file mode 100644
index 0000000..0632dc1
--- /dev/null
+++ b/src/lib/components/PurgeButton.svelte
@@ -0,0 +1,33 @@
+
+
+
diff --git a/src/lib/components/Tag.svelte b/src/lib/components/Tag.svelte
new file mode 100644
index 0000000..49fd4ad
--- /dev/null
+++ b/src/lib/components/Tag.svelte
@@ -0,0 +1,11 @@
+
+
+
+ {adjective}
+
diff --git a/src/lib/components/ToastContent.svelte b/src/lib/components/ToastContent.svelte
new file mode 100644
index 0000000..9bc72d6
--- /dev/null
+++ b/src/lib/components/ToastContent.svelte
@@ -0,0 +1,12 @@
+
+
+{$message}
diff --git a/src/lib/components/devtools/DevTools.svelte b/src/lib/components/devtools/DevTools.svelte
new file mode 100644
index 0000000..e69de29
diff --git a/src/lib/components/ui/alert/alert-description.svelte b/src/lib/components/ui/alert/alert-description.svelte
new file mode 100644
index 0000000..8bff2d5
--- /dev/null
+++ b/src/lib/components/ui/alert/alert-description.svelte
@@ -0,0 +1,17 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/alert/alert-title.svelte b/src/lib/components/ui/alert/alert-title.svelte
new file mode 100644
index 0000000..a13acf5
--- /dev/null
+++ b/src/lib/components/ui/alert/alert-title.svelte
@@ -0,0 +1,26 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/alert/alert.svelte b/src/lib/components/ui/alert/alert.svelte
new file mode 100644
index 0000000..ce8d2bf
--- /dev/null
+++ b/src/lib/components/ui/alert/alert.svelte
@@ -0,0 +1,41 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/alert/index.ts b/src/lib/components/ui/alert/index.ts
new file mode 100644
index 0000000..f9a276c
--- /dev/null
+++ b/src/lib/components/ui/alert/index.ts
@@ -0,0 +1,16 @@
+import Description from "./alert-description.svelte";
+import Title from "./alert-title.svelte";
+import Root from "./alert.svelte";
+
+
+export { alertVariants, type AlertVariant } from "./alert.svelte";
+
+export {
+ Root,
+ Description,
+ Title,
+ //
+ Root as Alert,
+ Description as AlertDescription,
+ Title as AlertTitle,
+};
diff --git a/src/lib/components/ui/badge/badge.svelte b/src/lib/components/ui/badge/badge.svelte
new file mode 100644
index 0000000..d6aa12f
--- /dev/null
+++ b/src/lib/components/ui/badge/badge.svelte
@@ -0,0 +1,49 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/badge/index.ts b/src/lib/components/ui/badge/index.ts
new file mode 100644
index 0000000..64e0aa9
--- /dev/null
+++ b/src/lib/components/ui/badge/index.ts
@@ -0,0 +1,2 @@
+export { default as Badge } from "./badge.svelte";
+export { badgeVariants, type BadgeVariant } from "./badge.svelte";
diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte
new file mode 100644
index 0000000..1520aa6
--- /dev/null
+++ b/src/lib/components/ui/button/button.svelte
@@ -0,0 +1,74 @@
+
+
+
+
+{#if href}
+
+ {@render children?.()}
+
+{:else}
+
+{/if}
diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts
new file mode 100644
index 0000000..33cd8be
--- /dev/null
+++ b/src/lib/components/ui/button/index.ts
@@ -0,0 +1,10 @@
+import Root, { type ButtonProps, type ButtonSize, type ButtonVariant, buttonVariants } from "./button.svelte";
+
+
+export {
+ Root as Button,
+ buttonVariants,
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+};
diff --git a/src/lib/components/ui/card/card-content.svelte b/src/lib/components/ui/card/card-content.svelte
new file mode 100644
index 0000000..ed9ef9f
--- /dev/null
+++ b/src/lib/components/ui/card/card-content.svelte
@@ -0,0 +1,17 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte
new file mode 100644
index 0000000..0f13d6d
--- /dev/null
+++ b/src/lib/components/ui/card/card-description.svelte
@@ -0,0 +1,17 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-footer.svelte b/src/lib/components/ui/card/card-footer.svelte
new file mode 100644
index 0000000..ff396fb
--- /dev/null
+++ b/src/lib/components/ui/card/card-footer.svelte
@@ -0,0 +1,17 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-header.svelte b/src/lib/components/ui/card/card-header.svelte
new file mode 100644
index 0000000..3c49057
--- /dev/null
+++ b/src/lib/components/ui/card/card-header.svelte
@@ -0,0 +1,17 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-title.svelte b/src/lib/components/ui/card/card-title.svelte
new file mode 100644
index 0000000..60e2a8b
--- /dev/null
+++ b/src/lib/components/ui/card/card-title.svelte
@@ -0,0 +1,26 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card.svelte b/src/lib/components/ui/card/card.svelte
new file mode 100644
index 0000000..0993569
--- /dev/null
+++ b/src/lib/components/ui/card/card.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts
new file mode 100644
index 0000000..e6baa72
--- /dev/null
+++ b/src/lib/components/ui/card/index.ts
@@ -0,0 +1,16 @@
+import Content from "./card-content.svelte";
+import Description from "./card-description.svelte";
+import Footer from "./card-footer.svelte";
+import Header from "./card-header.svelte";
+import Title from "./card-title.svelte";
+import Root from "./card.svelte";
+
+
+export {
+ Root as Card,
+ Content as CardContent,
+ Description as CardDescription,
+ Footer as CardFooter,
+ Header as CardHeader,
+ Title as CardTitle,
+};
diff --git a/src/lib/components/ui/dialog/dialog-body.svelte b/src/lib/components/ui/dialog/dialog-body.svelte
new file mode 100644
index 0000000..de4f9cc
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-body.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/dialog/dialog-content.svelte b/src/lib/components/ui/dialog/dialog-content.svelte
new file mode 100644
index 0000000..eb8b37f
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-content.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
+ {@render children?.()}
+
+
+ Close
+
+
+
diff --git a/src/lib/components/ui/dialog/dialog-description.svelte b/src/lib/components/ui/dialog/dialog-description.svelte
new file mode 100644
index 0000000..7da41b9
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/dialog/dialog-footer.svelte b/src/lib/components/ui/dialog/dialog-footer.svelte
new file mode 100644
index 0000000..732e0e5
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-footer.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/dialog/dialog-header.svelte b/src/lib/components/ui/dialog/dialog-header.svelte
new file mode 100644
index 0000000..de4f9cc
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-header.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/dialog/dialog-overlay.svelte b/src/lib/components/ui/dialog/dialog-overlay.svelte
new file mode 100644
index 0000000..7ead66c
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-overlay.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/ui/dialog/dialog-title.svelte b/src/lib/components/ui/dialog/dialog-title.svelte
new file mode 100644
index 0000000..2b51aa7
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-title.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/dialog/index.ts b/src/lib/components/ui/dialog/index.ts
new file mode 100644
index 0000000..4b2ffca
--- /dev/null
+++ b/src/lib/components/ui/dialog/index.ts
@@ -0,0 +1,40 @@
+import { Dialog as DialogPrimitive } from "bits-ui";
+import Body from "./dialog-body.svelte";
+import Content from "./dialog-content.svelte";
+import Description from "./dialog-description.svelte";
+import Footer from "./dialog-footer.svelte";
+import Header from "./dialog-header.svelte";
+import Overlay from "./dialog-overlay.svelte";
+import Title from "./dialog-title.svelte";
+
+
+const Root = DialogPrimitive.Root;
+const Trigger = DialogPrimitive.Trigger;
+const Close = DialogPrimitive.Close;
+const Portal = DialogPrimitive.Portal;
+
+export {
+ Root,
+ Title,
+ Portal,
+ Footer,
+ Body,
+ Header,
+ Trigger,
+ Overlay,
+ Content,
+ Description,
+ Close,
+ //
+ Root as Dialog,
+ Title as DialogTitle,
+ Portal as DialogPortal,
+ Footer as DialogFooter,
+ Body as DialogBody,
+ Header as DialogHeader,
+ Trigger as DialogTrigger,
+ Overlay as DialogOverlay,
+ Content as DialogContent,
+ Description as DialogDescription,
+ Close as DialogClose,
+};
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
new file mode 100644
index 0000000..6adb61b
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
@@ -0,0 +1,40 @@
+
+
+
+ {#snippet children({ checked, indeterminate })}
+
+ {#if indeterminate}
+
+ {:else}
+
+ {/if}
+
+ {@render childrenProp?.()}
+ {/snippet}
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
new file mode 100644
index 0000000..08f8fbd
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte
new file mode 100644
index 0000000..3a4c63f
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte
new file mode 100644
index 0000000..db7bb3b
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte
new file mode 100644
index 0000000..d485e53
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
new file mode 100644
index 0000000..e81728f
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
@@ -0,0 +1,30 @@
+
+
+
+ {#snippet children({ checked })}
+
+ {#if checked}
+
+ {/if}
+
+ {@render childrenProp?.({ checked })}
+ {/snippet}
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte
new file mode 100644
index 0000000..ad44db5
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
new file mode 100644
index 0000000..01c26c2
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
new file mode 100644
index 0000000..1c0828f
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
new file mode 100644
index 0000000..a71c59a
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
@@ -0,0 +1,28 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/src/lib/components/ui/dropdown-menu/index.ts b/src/lib/components/ui/dropdown-menu/index.ts
new file mode 100644
index 0000000..40c4502
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/index.ts
@@ -0,0 +1,50 @@
+import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
+import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
+import Content from "./dropdown-menu-content.svelte";
+import GroupHeading from "./dropdown-menu-group-heading.svelte";
+import Item from "./dropdown-menu-item.svelte";
+import Label from "./dropdown-menu-label.svelte";
+import RadioItem from "./dropdown-menu-radio-item.svelte";
+import Separator from "./dropdown-menu-separator.svelte";
+import Shortcut from "./dropdown-menu-shortcut.svelte";
+import SubContent from "./dropdown-menu-sub-content.svelte";
+import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
+
+const Sub = DropdownMenuPrimitive.Sub;
+const Root = DropdownMenuPrimitive.Root;
+const Trigger = DropdownMenuPrimitive.Trigger;
+const Group = DropdownMenuPrimitive.Group;
+const RadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+export {
+ CheckboxItem,
+ Content,
+ Root as DropdownMenu,
+ CheckboxItem as DropdownMenuCheckboxItem,
+ Content as DropdownMenuContent,
+ Group as DropdownMenuGroup,
+ GroupHeading as DropdownMenuGroupHeading,
+ Item as DropdownMenuItem,
+ Label as DropdownMenuLabel,
+ RadioGroup as DropdownMenuRadioGroup,
+ RadioItem as DropdownMenuRadioItem,
+ Separator as DropdownMenuSeparator,
+ Shortcut as DropdownMenuShortcut,
+ Sub as DropdownMenuSub,
+ SubContent as DropdownMenuSubContent,
+ SubTrigger as DropdownMenuSubTrigger,
+ Trigger as DropdownMenuTrigger,
+ Group,
+ GroupHeading,
+ Item,
+ Label,
+ RadioGroup,
+ RadioItem,
+ Root,
+ Separator,
+ Shortcut,
+ Sub,
+ SubContent,
+ SubTrigger,
+ Trigger,
+};
diff --git a/src/lib/components/ui/input/index.ts b/src/lib/components/ui/input/index.ts
new file mode 100644
index 0000000..3b8dffd
--- /dev/null
+++ b/src/lib/components/ui/input/index.ts
@@ -0,0 +1,6 @@
+import Root from "./input.svelte";
+
+
+export {
+ Root as Input,
+};
diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte
new file mode 100644
index 0000000..fc782a5
--- /dev/null
+++ b/src/lib/components/ui/input/input.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+
diff --git a/src/lib/components/ui/label/index.ts b/src/lib/components/ui/label/index.ts
new file mode 100644
index 0000000..ceeb662
--- /dev/null
+++ b/src/lib/components/ui/label/index.ts
@@ -0,0 +1,8 @@
+import Root from "./label.svelte";
+
+
+export {
+ Root,
+ //
+ Root as Label,
+};
diff --git a/src/lib/components/ui/label/label.svelte b/src/lib/components/ui/label/label.svelte
new file mode 100644
index 0000000..552fa9c
--- /dev/null
+++ b/src/lib/components/ui/label/label.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/ui/sonner/index.ts b/src/lib/components/ui/sonner/index.ts
new file mode 100644
index 0000000..1ad9f4a
--- /dev/null
+++ b/src/lib/components/ui/sonner/index.ts
@@ -0,0 +1 @@
+export { default as Toaster } from "./sonner.svelte";
diff --git a/src/lib/components/ui/sonner/sonner.svelte b/src/lib/components/ui/sonner/sonner.svelte
new file mode 100644
index 0000000..750ba0a
--- /dev/null
+++ b/src/lib/components/ui/sonner/sonner.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/src/lib/components/ui/tabs/index.ts b/src/lib/components/ui/tabs/index.ts
new file mode 100644
index 0000000..1abc044
--- /dev/null
+++ b/src/lib/components/ui/tabs/index.ts
@@ -0,0 +1,19 @@
+import { Tabs as TabsPrimitive } from "bits-ui";
+import Content from "./tabs-content.svelte";
+import List from "./tabs-list.svelte";
+import Trigger from "./tabs-trigger.svelte";
+
+
+const Root = TabsPrimitive.Root;
+
+export {
+ Root,
+ Content,
+ List,
+ Trigger,
+ //
+ Root as Tabs,
+ Content as TabsContent,
+ List as TabsList,
+ Trigger as TabsTrigger,
+};
diff --git a/src/lib/components/ui/tabs/tabs-content.svelte b/src/lib/components/ui/tabs/tabs-content.svelte
new file mode 100644
index 0000000..1a6f956
--- /dev/null
+++ b/src/lib/components/ui/tabs/tabs-content.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/ui/tabs/tabs-list.svelte b/src/lib/components/ui/tabs/tabs-list.svelte
new file mode 100644
index 0000000..411ad56
--- /dev/null
+++ b/src/lib/components/ui/tabs/tabs-list.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/ui/tabs/tabs-trigger.svelte b/src/lib/components/ui/tabs/tabs-trigger.svelte
new file mode 100644
index 0000000..754ace3
--- /dev/null
+++ b/src/lib/components/ui/tabs/tabs-trigger.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/fastcdc/index.ts b/src/lib/fastcdc/index.ts
new file mode 100644
index 0000000..1377c70
--- /dev/null
+++ b/src/lib/fastcdc/index.ts
@@ -0,0 +1,217 @@
+import { createTable } from "$lib/fastcdc/table";
+import type { Chunk, Options } from "$lib/fastcdc/types";
+import { concat } from "$lib/tulip/utils/buffer";
+
+
+const MIN_SIZE = 64;
+const MAX_SIZE = 1 << 30; // 1GiB
+
+/**
+ * Chunks the given data using FastCDC and a rolling Gear hash.
+ * See https://en.wikipedia.org/wiki/Rolling_hash#Gear_fingerprint_and_content-based_chunking_algorithm_FastCDC
+ * Paper https://www.usenix.org/system/files/conference/atc16/atc16-paper-xia.pdf
+ * Based on https://github.com/jotfs/fastcdc-go/tree/v0.2.0
+ *
+ * @param data
+ * @param options
+ */
+export function* fastCDC(data: Uint8Array, options: Options): Generator {
+ const { averageSize, minSize, maxSize, normalization, seed } = setDefaults(options);
+ const [maskBelow, maskAbove] = computeBitmasks(averageSize, normalization);
+ const table = createTable(seed);
+
+ let fingerprint = 0n;
+ let start = 0;
+
+ for (let i = 0; i < data.length; i++) {
+ const value = data[i];
+ if (typeof value == "undefined") {
+ throw new Error("Should never happen");
+ }
+
+ const len = i - start + 1; // one for the bias, jk
+ const isBelowMinSize = len < minSize;
+ const isLastByte = i === data.length - 1;
+
+ const lookup = table[value];
+ if (typeof lookup == "undefined") {
+ throw new Error("Should never happen");
+ }
+
+ // Compute Gear hash
+ fingerprint = BigInt.asUintN(64, (fingerprint << 1n) + lookup);
+
+ // Skip the first minSize bytes, and kickstart the hash
+ if (isBelowMinSize && ! isLastByte) {
+ continue;
+ }
+
+ const mask = len < averageSize ? maskBelow : maskAbove;
+ const isBoundary = isChunkBoundary(fingerprint, mask);
+ const isMaxSizeExceeded = len >= maxSize;
+
+ // Yield if there is either a natural boundary, the max size is exceeded or we are at the end of the data
+ if (isBoundary || isMaxSizeExceeded || isLastByte) {
+ yield {
+ data: data.subarray(start, i + 1),
+ fingerprint: fingerprint,
+ };
+
+ start = i + 1;
+ fingerprint = 0n;
+ }
+ }
+}
+
+/**
+ * Chunks the data from the given reader using FastCDC and a rolling Gear hash.
+ *
+ * @param reader
+ * @param options
+ */
+export async function* fastCDC2(
+ reader: ReadableStreamDefaultReader,
+ options: Options,
+): AsyncGenerator {
+ const { averageSize, minSize, maxSize, normalization, seed } = setDefaults(options);
+ const [maskBelow, maskAbove] = computeBitmasks(averageSize, normalization);
+ const table = createTable(seed);
+
+ let fingerprint = 0n;
+ let previous: Uint8Array[] = [];
+ let byteCounter = 0;
+
+ while (true) {
+ const { value, done } = await reader.read();
+
+ if (done) {
+ if (previous.length > 0) {
+ yield {
+ data: concat(...previous),
+ fingerprint: fingerprint,
+ };
+ }
+
+ break;
+ }
+
+ for (let i = 0, start = 0; i < value.length; i++) {
+ byteCounter++;
+
+ const byte = value[i];
+ if (typeof byte == "undefined") {
+ throw new Error("Should never happen");
+ }
+
+ const lookup = table[byte];
+ if (typeof lookup == "undefined") {
+ throw new Error("Should never happen");
+ }
+
+ const isBelowMinSize = byteCounter < minSize;
+ const isLastByte = i === value.length - 1;
+
+ // Compute Gear hash
+ fingerprint = BigInt.asUintN(64, (fingerprint << 1n) + lookup);
+
+ // Skip the first minSize bytes, and kickstart the hash
+ if (isBelowMinSize && ! isLastByte) {
+ continue;
+ }
+
+ const mask = byteCounter < averageSize ? maskBelow : maskAbove;
+ const isBoundary = isChunkBoundary(fingerprint, mask);
+ const isMaxSizeExceeded = byteCounter >= maxSize;
+
+ // Yield if there is either a natural boundary or the max size is exceeded
+ if (isBoundary || isMaxSizeExceeded) {
+ const part = value.subarray(start, i + 1);
+ const data = concat(...previous, part);
+
+ yield {
+ data,
+ fingerprint,
+ };
+
+ fingerprint = 0n;
+ start = i + 1;
+ byteCounter = 0;
+ previous = [];
+ }
+ else if (isLastByte) {
+ previous.push(value.subarray(start, i + 1));
+ }
+ }
+ }
+}
+
+/**
+ * Set the defaults for the given options.
+ * Also checks, whether the given values are valid.
+ *
+ * @param options
+ */
+function setDefaults(options: Options): Required {
+ const { averageSize } = options;
+ const {
+ minSize = Math.floor(averageSize / 4),
+ maxSize = Math.floor(averageSize * 4),
+ normalization = 2,
+ seed = 0n,
+ } = options;
+
+ if (averageSize <= 0) {
+ throw new Error("averageSize must be greater than 0");
+ }
+ if (minSize < MIN_SIZE || minSize > MAX_SIZE) {
+ throw new Error("minSize must be in range 64B to 1GiB");
+ }
+ if (maxSize < MIN_SIZE || maxSize > MAX_SIZE) {
+ throw new Error("maxSize must be in range 64B to 1GiB");
+ }
+ if (maxSize <= minSize) {
+ throw new Error("minSize must be less than option maxSize");
+ }
+ if (averageSize > maxSize || averageSize < minSize) {
+ throw new Error("averageSize must be between minSize and maxSize");
+ }
+
+ return {
+ averageSize,
+ minSize,
+ maxSize,
+ normalization,
+ seed,
+ };
+}
+
+/**
+ * Computes two bitmasks for chunk boundary determination.
+ * The first bitmask should be used for when the current chunk length is below the given average size, while the second
+ * bitmask is for when it's above.
+ *
+ * @param averageSize
+ * @param normalization
+ */
+function computeBitmasks(averageSize: number, normalization: 0 | 1 | 2 | 3): [bigint, bigint] {
+ const bits = Math.round(Math.log2(averageSize));
+ const smallBits = bits + normalization;
+ const largeBits = bits - normalization;
+ const maskBelow = BigInt((1 << smallBits) - 1);
+ const maskAbove = BigInt((1 << largeBits) - 1);
+
+ return [
+ maskBelow,
+ maskAbove,
+ ];
+}
+
+/**
+ * Checks if a fingerprint defines a chunk boundary.
+ *
+ * @param fingerprint
+ * @param mask
+ */
+function isChunkBoundary(fingerprint: bigint, mask: bigint): boolean {
+ return (fingerprint & mask) == 0n;
+}
diff --git a/src/lib/fastcdc/table.ts b/src/lib/fastcdc/table.ts
new file mode 100644
index 0000000..1da1ce5
--- /dev/null
+++ b/src/lib/fastcdc/table.ts
@@ -0,0 +1,91 @@
+import type { Table } from "$lib/fastcdc/types";
+
+
+/**
+ * Create a new table with the given seed.
+ *
+ * @param seed
+ */
+export function createTable(seed: bigint): Table {
+ const table = [...TABLE];
+
+ for (let i = 0; i < table.length; i++) {
+ const value = table[i];
+ if (typeof value == "undefined") {
+ throw new Error("Should never happen");
+ }
+
+ table[i] = value ^ seed;
+ }
+
+ return table;
+}
+
+// 256 random uint64s for the rolling hash function
+// Borrowed from https://github.com/jotfs/fastcdc-go/blob/v0.2.0/fastcdc.go
+const TABLE: Table = [
+ 0xe80e8d55032474b3n, 0x11b25b61f5924e15n, 0x03aa5bd82a9eb669n, 0xc45a153ef107a38cn,
+ 0xeac874b86f0f57b9n, 0xa5ccedec95ec79c7n, 0xe15a3320ad42ac0an, 0x5ed3583fa63cec15n,
+ 0xcd497bf624a4451dn, 0xf9ade5b059683605n, 0x773940c03fb11ca1n, 0xa36b16e4a6ae15b2n,
+ 0x67afd1adb5a89eacn, 0xc44c75ee32f0038en, 0x2101790f365c0967n, 0x76415c64a222fc4an,
+ 0x579929249a1e577an, 0xe4762fc41fdbf750n, 0xea52198e57dfcdccn, 0xe2535aafe30b4281n,
+ 0xcb1a1bd6c77c9056n, 0x5a1aa9bfc4612a62n, 0x15a728aef8943eb5n, 0x2f8f09738a8ec8d9n,
+ 0x200f3dec9fac8074n, 0x0fa9a7b1e0d318dfn, 0x06c0804ffd0d8e3an, 0x630cbc412669dd25n,
+ 0x10e34f85f4b10285n, 0x2a6fe8164b9b6410n, 0xcacb57d857d55810n, 0x77f8a3a36ff11b46n,
+ 0x66af517e0dc3003en, 0x76c073c789b4009an, 0x853230dbb529f22an, 0x1e9e9c09a1f77e56n,
+ 0x1e871223802ee65dn, 0x37fe4588718ff813n, 0x10088539f30db464n, 0x366f7470b80b72d1n,
+ 0x33f2634d9a6b31dbn, 0xd43917751d69ea18n, 0xa0f492bc1aa7b8den, 0x3f94e5a8054edd20n,
+ 0xedfd6e25eb8b1dbfn, 0x759517a54f196a56n, 0xe81d5006ec7b6b17n, 0x8dd8385fa894a6b7n,
+ 0x45f4d5467b0d6f91n, 0xa1f894699de22bc8n, 0x33829d09ef93e0fen, 0x3e29e250caed603cn,
+ 0xf7382cba7f63a45en, 0x970f95412bb569d1n, 0xc7fcea456d356b4bn, 0x723042513f3e7a57n,
+ 0x17ae7688de3596f1n, 0x27ac1fcd7cd23c1an, 0xf429beeb78b3f71fn, 0xd0780692fb93a3f9n,
+ 0x9f507e28a7c9842fn, 0x56001ad536e433aen, 0x7e1dd1ecf58be306n, 0x15fee353aa233fc6n,
+ 0xb033a0730b7638e8n, 0xeb593ad6bd2406d1n, 0x7c86502574d0f133n, 0xce3b008d4ccb4be7n,
+ 0xf8566e3d383594c8n, 0xb2c261e9b7af4429n, 0xf685e7e253799dbbn, 0x05d33ed60a494cbcn,
+ 0xeaf88d55a4cb0d1an, 0x3ee9368a902415a1n, 0x8980fe6a8493a9a4n, 0x358ed008cb448631n,
+ 0xd0cb7e37b46824b8n, 0xe9bc375c0bc94f84n, 0xea0bf1d8e6b55bb3n, 0xb66a60d0f9f6f297n,
+ 0x66db2cc4807b3758n, 0x7e4e014afbca8b4dn, 0xa5686a4938b0c730n, 0xa5f0d7353d623316n,
+ 0x26e38c349242d5e8n, 0xeeefa80a29858e30n, 0x8915cb912aa67386n, 0x4b957a47bfc420d4n,
+ 0xbb53d051a895f7e1n, 0x09f5e3235f6911cen, 0x416b98e695cfb7cen, 0x97a08183344c5c86n,
+ 0xbf68e0791839a861n, 0xea05dde59ed3ed56n, 0x0ca732280beda160n, 0xac748ed62fe7f4e2n,
+ 0xc686da075cf6e151n, 0xe1ba5658f4af05c8n, 0xe9ff09fbeb67cc35n, 0xafaea9470323b28dn,
+ 0x0291e8db5bb0ac2an, 0x342072a9bbee77aen, 0x03147eed6b3d0a9cn, 0x21379d4de31dbadbn,
+ 0x2388d965226fb986n, 0x52c96988bfebabfan, 0xa6fc29896595bc2dn, 0x38fa4af70aa46b8bn,
+ 0xa688dd13939421een, 0x99d5275d9b1415dan, 0x453d31bb4fe73631n, 0xde51debc1fbe3356n,
+ 0x75a3c847a06c622fn, 0xe80e32755d272579n, 0x5444052250d8ec0dn, 0x8f17dfda19580a3bn,
+ 0xf6b3e9363a185e42n, 0x7a42adec6868732fn, 0x32cb6a07629203a2n, 0x1eca8957defe56d9n,
+ 0x9fa85e4bc78ff9edn, 0x20ff07224a499ca7n, 0x3fa6295ff9682c70n, 0xe3d5b1e3ce993effn,
+ 0xa341209362e0b79an, 0x64bd9eae5712ffe8n, 0xceebb537babbd12an, 0x5586ef404315954fn,
+ 0x46c3085c938ab51an, 0xa82ccb9199907ceen, 0x8c51b6690a3523c8n, 0xc4dbd4c9ae518332n,
+ 0x979898dbb23db7b2n, 0x1b5b585e6f672a9dn, 0xce284da7c4903810n, 0x841166e8bb5f1c4fn,
+ 0xb7d884a3fceca7d0n, 0xa76468f5a4572374n, 0xc10c45f49ee9513dn, 0x68f9a5663c1908c9n,
+ 0x0095a13476a6339dn, 0xd1d7516ffbe9c679n, 0xfd94ab0c9726f938n, 0x627468bbdb27c959n,
+ 0xedc3f8988e4a8c9an, 0x58efd33f0dfaa499n, 0x21e37d7e2ef4ac8bn, 0x297f9ab5586259c6n,
+ 0xda3ba4dc6cb9617dn, 0xae11d8d9de2284d2n, 0xcfeed88cb3729865n, 0xefc2f9e4f03e2633n,
+ 0x8226393e8f0855a4n, 0xd6e25fd7acf3a767n, 0x435784c3bfd6d14an, 0xf97142e6343fe757n,
+ 0xd73b9fe826352f85n, 0x6c3ac444b5b2bd76n, 0xd8e88f3e9fd4a3fdn, 0x31e50875c36f3460n,
+ 0xa824f1bf88cf4d44n, 0x54a4d2c8f5f25899n, 0xbff254637ce3b1e6n, 0xa02cfe92561b3caan,
+ 0x7bedb4edee9f0af7n, 0x879c0620ac49a102n, 0xa12c4ccd23b332e7n, 0x09a5ff47bf94ed1en,
+ 0x7b62f43cd3046fa0n, 0xaa3af0476b9c2fb9n, 0x22e55301abebba8en, 0x3a6035c42747bd58n,
+ 0x1705373106c8ec07n, 0xb1f660de828d0628n, 0x065fe82d89ca563dn, 0xf555c2d8074d516dn,
+ 0x6bb6c186b423ee99n, 0x54a807be6f3120a8n, 0x8a3c7fe2f88860b8n, 0xbeffc344f5118e81n,
+ 0xd686e80b7d1bd268n, 0x661aef4ef5e5e88bn, 0x5bf256c654cd1ddan, 0x9adb1ab85d7640f4n,
+ 0x68449238920833a2n, 0x843279f4cebcb044n, 0xc8710cdefa93f7bbn, 0x236943294538f3e6n,
+ 0x80d7d136c486d0b4n, 0x61653956b28851d3n, 0x3f843be9a9a956b5n, 0xf73cfbbf137987e5n,
+ 0xcf0cb6dee8ceac2cn, 0x50c401f52f185caen, 0xbdbe89ce735c4c1cn, 0xeef3ade9c0570bc7n,
+ 0xbe8b066f8f64cbf6n, 0x5238d6131705dcb9n, 0x20219086c950e9f6n, 0x634468d9ed74de02n,
+ 0x0aba4b3d705c7fa5n, 0x3374416f725a6672n, 0xe7378bdf7beb3bc6n, 0x0f7b6a1b1cee565bn,
+ 0x234e4c41b0c33e64n, 0x4efa9a0c3f21fe28n, 0x1167fc551643e514n, 0x9f81a69d3eb01fa4n,
+ 0xdb75c22b12306ed0n, 0xe25055d738fc9686n, 0x9f9f167a3f8507bbn, 0x195f8336d3fbe4d3n,
+ 0x8442b6feffdcb6f6n, 0x1e07ed24746ffde9n, 0x140e31462d555266n, 0x8bd0ce515ae1406en,
+ 0x2c0be0042b5584b3n, 0x35a23d0e15d45a60n, 0xc14f1ba147d9bc83n, 0xbbf168691264b23fn,
+ 0xad2cc7b57e589aden, 0x9501963154c7815cn, 0x9664afa6b8d67d47n, 0x7f9e5101fea0a81cn,
+ 0x45ecffb610d25bfdn, 0x3157f7aecf9b6ab3n, 0xc43ca6f88d87501dn, 0x9576ff838dee38dcn,
+ 0x93f21afe0ce1c7d7n, 0xceac699df343d8f9n, 0x2fec49e29f03398dn, 0x8805ccd5730281edn,
+ 0xf9fc16fc750a8e59n, 0x35308cc771adf736n, 0x4a57b7c9ee2b7defn, 0x03a4c6cdc937a02an,
+ 0x6c9a8a269fc8c4fcn, 0x4681decec7a03f43n, 0x342eecded1353ef9n, 0x8be0552d8413a867n,
+ 0xc7b4ac51beda8be8n, 0xebcc64fb719842c0n, 0xde8e4c7fb6d40c1cn, 0xcc8263b62f9738b1n,
+ 0xd3cfc0f86511929an, 0x466024ce8bb226ean, 0x459ff690253a3c18n, 0x98b27e9d91284c9cn,
+ 0x75c3ae8aa3af373dn, 0xfbf8f8e79a866ffcn, 0x32327f59d0662799n, 0x8228b57e729e9830n,
+ 0x065ceb7a18381b58n, 0xd2177671a31dc5ffn, 0x90cd801f2f8701f9n, 0x9d714428471c65fen,
+];
diff --git a/src/lib/fastcdc/types.ts b/src/lib/fastcdc/types.ts
new file mode 100644
index 0000000..cd4cf3d
--- /dev/null
+++ b/src/lib/fastcdc/types.ts
@@ -0,0 +1,28 @@
+export type Table = bigint[];
+
+
+export interface Options {
+ // Target chunk size. Typically, a power of 2. It must be in the range 64B to 1GiB
+ averageSize: number;
+
+ // (Optional) The minimum allowed chunk size. By default, it's set to averageSize / 4.
+ minSize?: number;
+
+ // (Optional) The maximum allowed chunk size. By default, it's set to averageSize * 4.
+ maxSize?: number;
+
+ // (Optional) Sets the chunk normalization level. It may be set to 0, 1, 2 or 3. By default, it's set to 2.
+ normalization?: 0 | 1 | 2 | 3;
+
+ // (Optional) Alters the lookup table of the rolling hash algorithm to mitigate chunk-size based fingerprinting
+ // attacks. It may be set to a random uint64.
+ seed?: bigint;
+}
+
+export interface Chunk {
+ // This chunk's data.
+ data: Uint8Array;
+
+ // Rolling hash for this chunk.
+ fingerprint: bigint;
+}
diff --git a/src/lib/globals/index.ts b/src/lib/globals/index.ts
new file mode 100644
index 0000000..c5718b4
--- /dev/null
+++ b/src/lib/globals/index.ts
@@ -0,0 +1,4 @@
+import { GlobalsState } from "$lib/globals/state.svelte";
+
+
+export const globals = new GlobalsState();
diff --git a/src/lib/globals/state.svelte.ts b/src/lib/globals/state.svelte.ts
new file mode 100644
index 0000000..61c8a89
--- /dev/null
+++ b/src/lib/globals/state.svelte.ts
@@ -0,0 +1,14 @@
+import type { KeyValStore } from "$lib/keyval";
+
+
+export class GlobalsState {
+ #keyval = $state(null);
+
+ get keyval(): KeyValStore | null {
+ return this.#keyval;
+ }
+
+ setKeyValStore(store: KeyValStore | null) {
+ this.#keyval = store;
+ }
+}
diff --git a/src/lib/index.ts b/src/lib/index.ts
new file mode 100644
index 0000000..856f2b6
--- /dev/null
+++ b/src/lib/index.ts
@@ -0,0 +1 @@
+// place files you want to import through the `$lib` alias in this folder.
diff --git a/src/lib/keyval/index.ts b/src/lib/keyval/index.ts
new file mode 100644
index 0000000..485f91a
--- /dev/null
+++ b/src/lib/keyval/index.ts
@@ -0,0 +1,69 @@
+const DATABASE_NAME = "keyval";
+const OBJECT_STORE_NAME = "keyval";
+const KEY_PATH = "key";
+
+
+export interface KeyValStore {
+ get: (key: string) => Promise;
+ set: (key: string, value: T) => Promise;
+ delete: (key: string) => Promise;
+}
+
+export function openDB(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DATABASE_NAME, 1);
+
+ request.onupgradeneeded = () => {
+ const db = request.result;
+
+ if (! db.objectStoreNames.contains(OBJECT_STORE_NAME)) {
+ const objectStore = db.createObjectStore(OBJECT_STORE_NAME, { keyPath: KEY_PATH });
+
+ objectStore.createIndex(KEY_PATH, KEY_PATH, { unique: true });
+ }
+ };
+
+ request.onerror = reject;
+ request.onsuccess = () => {
+ const db = request.result;
+
+ resolve({
+ get: (key: string) => new Promise((resolve, reject) => {
+ const readTransaction = db.transaction(DATABASE_NAME, "readonly");
+ const readStore = readTransaction.objectStore(OBJECT_STORE_NAME);
+ const readRequest = readStore.get(key);
+
+ readRequest.onerror = reject;
+ readRequest.onsuccess = () => {
+ resolve(readRequest.result?.value ?? null);
+ };
+ }),
+ set: async (key: string, value: T | null) => new Promise((resolve, reject) => {
+ const modifyTransaction = db.transaction(DATABASE_NAME, "readwrite");
+ const modifyStore = modifyTransaction.objectStore(OBJECT_STORE_NAME);
+
+ if (value === null) {
+ const deleteRequest = modifyStore.delete(key);
+
+ deleteRequest.onerror = reject;
+ deleteRequest.onsuccess = () => resolve();
+ }
+ else {
+ const writeRequest = modifyStore.put({ key, value });
+
+ writeRequest.onerror = reject;
+ writeRequest.onsuccess = () => resolve();
+ }
+ }),
+ delete: (key) => new Promise((resolve, reject) => {
+ const modifyTransaction = db.transaction(DATABASE_NAME, "readwrite");
+ const modifyStore = modifyTransaction.objectStore(OBJECT_STORE_NAME);
+ const deleteRequest = modifyStore.delete(key);
+
+ deleteRequest.onerror = reject;
+ deleteRequest.onsuccess = () => resolve();
+ }),
+ });
+ };
+ });
+}
diff --git a/src/lib/reform/components/form-button.svelte b/src/lib/reform/components/form-button.svelte
new file mode 100644
index 0000000..cd80196
--- /dev/null
+++ b/src/lib/reform/components/form-button.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/src/lib/reform/components/form-control.svelte b/src/lib/reform/components/form-control.svelte
new file mode 100644
index 0000000..dfaf22c
--- /dev/null
+++ b/src/lib/reform/components/form-control.svelte
@@ -0,0 +1,51 @@
+
+
+
+
+
+ {@render children?.({ props: control.props, error: control.error })}
+
diff --git a/src/lib/reform/components/form-field-error.svelte b/src/lib/reform/components/form-field-error.svelte
new file mode 100644
index 0000000..e51cd6a
--- /dev/null
+++ b/src/lib/reform/components/form-field-error.svelte
@@ -0,0 +1,20 @@
+
+
+{#if control.error}
+
+ {control.error}
+
+{/if}
diff --git a/src/lib/reform/components/form-label.svelte b/src/lib/reform/components/form-label.svelte
new file mode 100644
index 0000000..9474e05
--- /dev/null
+++ b/src/lib/reform/components/form-label.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/src/lib/reform/components/index.ts b/src/lib/reform/components/index.ts
new file mode 100644
index 0000000..1a1d491
--- /dev/null
+++ b/src/lib/reform/components/index.ts
@@ -0,0 +1,16 @@
+import Button from "./form-button.svelte";
+import Control from "./form-control.svelte";
+import FieldError from "./form-field-error.svelte";
+import Label from "./form-label.svelte";
+
+
+export {
+ Control as FormControl,
+ // Description as FormDescription,
+ Label as FormLabel,
+ FieldError as FormFieldError,
+ // Fieldset as FormFieldset,
+ // Legend as FormLegend,
+ // ElementField as FormElementField,
+ Button as FormButton,
+};
diff --git a/src/lib/reform/context.ts b/src/lib/reform/context.ts
new file mode 100644
index 0000000..0fdc0a0
--- /dev/null
+++ b/src/lib/reform/context.ts
@@ -0,0 +1,18 @@
+import { type FormControlInit, FormControlState } from "$lib/reform/state/form-control.svelte";
+import { getContext, hasContext, setContext } from "svelte";
+import type { AnyZodObject } from "zod";
+
+
+const FORM_CONTROL_CONTEXT_KEY = Symbol();
+
+export function useFormControl(init: FormControlInit) {
+ return setContext(FORM_CONTROL_CONTEXT_KEY, new FormControlState(init));
+}
+
+export function getFormControl(): FormControlState {
+ if (! hasContext(FORM_CONTROL_CONTEXT_KEY)) {
+ throw new Error("getFormControl must be used within a FormControl");
+ }
+
+ return getContext>(FORM_CONTROL_CONTEXT_KEY);
+}
diff --git a/src/lib/reform/index.ts b/src/lib/reform/index.ts
new file mode 100644
index 0000000..d686268
--- /dev/null
+++ b/src/lib/reform/index.ts
@@ -0,0 +1,13 @@
+import { FormState } from "$lib/reform/state/form.svelte";
+import { type AnyZodObject, z } from "zod";
+
+
+export interface ReformOptions {
+ schema: ZodType;
+ initialState: z.infer;
+ onSubmit?: (data: z.infer) => void;
+}
+
+export function reform(options: ReformOptions): FormState {
+ return new FormState(options);
+}
diff --git a/src/lib/reform/state/form-control.svelte.ts b/src/lib/reform/state/form-control.svelte.ts
new file mode 100644
index 0000000..9b5b3c5
--- /dev/null
+++ b/src/lib/reform/state/form-control.svelte.ts
@@ -0,0 +1,63 @@
+import type { FormState } from "$lib/reform/state/form.svelte";
+import type { allKeys } from "$lib/reform/types";
+import { untrack } from "svelte";
+import type { HTMLInputAttributes } from "svelte/elements";
+import { type AnyZodObject, z } from "zod";
+
+
+export interface FormControlInit {
+ form: FormState;
+ field: allKeys>;
+ id: string;
+}
+
+export type FormControlAttributes = HTMLInputAttributes;
+
+export class FormControlState {
+ private readonly form: FormState;
+ private readonly field: allKeys>;
+ private readonly id: string;
+
+ /**
+ * The props to be passed to the control.
+ */
+ readonly props = $derived.by(() => ({
+ id: this.id,
+ name: this.field as string,
+ class: "group-data-[rf-error=true]/control:border-destructive",
+
+ // name: this.field.name,
+ // 'data-fs-error': getDataFsError(this.field.errors),
+ // 'aria-describedby': getAriaDescribedBy({
+ // fieldErrorsId: this.field.errorNode?.id,
+ // descriptionId: this.field.descriptionNode?.id,
+ // errors: this.field.errors,
+ // }),
+ // 'aria-invalid': getAriaInvalid(this.field.errors),
+ // 'aria-required': getAriaRequired(this.field.constraints),
+ // 'data-fs-control': '',
+ }) satisfies FormControlAttributes);
+
+ constructor(init: FormControlInit) {
+ this.form = init.form;
+ this.field = init.field;
+ this.id = init.id;
+
+ $effect(() => {
+ // Mark the value as a dependency for this effect
+ this.value;
+
+ if (untrack(() => this.error) !== null) {
+ untrack(() => this.form.resetError(this.field));
+ }
+ });
+ }
+
+ get value() {
+ return this.form.state[this.field];
+ }
+
+ get error() {
+ return this.form.errors[this.field] ?? null;
+ }
+}
diff --git a/src/lib/reform/state/form.svelte.ts b/src/lib/reform/state/form.svelte.ts
new file mode 100644
index 0000000..d3ca23e
--- /dev/null
+++ b/src/lib/reform/state/form.svelte.ts
@@ -0,0 +1,101 @@
+import type { allKeys } from "$lib/reform/types";
+import { type AnyZodObject, z } from "zod";
+
+
+interface FormOptions {
+ schema: ZodType;
+ initialState: z.infer;
+ onSubmit?: (data: z.infer) => Promise | void;
+}
+
+type ErrorMap = Partial>, string>>;
+
+export class FormState {
+ readonly #state: z.infer = $state({});
+ #errors = $state>({});
+ #submitting = $state(false);
+ readonly props = $derived.by(() => ({
+ onsubmit: async (event: Event) => {
+ event.preventDefault();
+
+ const values = this.values();
+
+ this.revalidate(values);
+
+ if (! this.hasErrors()) {
+ this.#submitting = true;
+
+ try {
+ await this.options.onSubmit?.(values);
+ }
+ catch (error) {
+ throw error;
+ }
+ finally {
+ this.#submitting = false;
+ }
+ }
+ },
+ }));
+
+ constructor(private readonly options: FormOptions) {
+ this.#state = options.initialState;
+ }
+
+ get state(): z.infer {
+ return this.#state;
+ }
+
+ get errors(): ErrorMap {
+ return this.#errors;
+ }
+
+ get submitting(): boolean {
+ return this.#submitting;
+ }
+
+ values(): z.infer {
+ return $state.snapshot(this.state) as z.infer;
+ }
+
+ revalidate(values = this.values()): void {
+ const result = this.options.schema.safeParse(values);
+ if (result.success) {
+ return;
+ }
+
+ const { fieldErrors } = result.error.flatten();
+ const errors = Object.entries(fieldErrors).reduce((carry, [key, value]) => {
+ if (typeof value == "undefined") {
+ return carry;
+ }
+
+ return {
+ ...carry,
+ [key]: value[0],
+ };
+ }, {});
+
+ this.setErrors(errors);
+ }
+
+ setErrors(errors: ErrorMap): void {
+ this.#errors = errors;
+ }
+
+ setFieldError(field: allKeys>, error: string): void {
+ this.#errors[field] = error;
+ }
+
+ hasError(field: allKeys>): boolean {
+ return this.#errors[field] !== undefined;
+ }
+
+ hasErrors(): boolean {
+ return Object.keys(this.#errors).length > 0;
+ }
+
+ resetError(field: allKeys>): void {
+ delete this.#errors[field];
+ }
+}
diff --git a/src/lib/reform/types.ts b/src/lib/reform/types.ts
new file mode 100644
index 0000000..769740a
--- /dev/null
+++ b/src/lib/reform/types.ts
@@ -0,0 +1 @@
+export declare type allKeys = T extends any ? keyof T : never;
diff --git a/src/lib/server/crypto/encrypt.ts b/src/lib/server/crypto/encrypt.ts
new file mode 100644
index 0000000..4d3d8a6
--- /dev/null
+++ b/src/lib/server/crypto/encrypt.ts
@@ -0,0 +1,13 @@
+import { decrypt as tulipDecrypt, encrypt as tulipEncrypt } from "$lib/tulip";
+import { randomBytes } from "@noble/ciphers/webcrypto";
+
+
+const encryptionKey = randomBytes(32);
+
+export function encrypt(data: Uint8Array): Uint8Array {
+ return tulipEncrypt(encryptionKey, data);
+}
+
+export function decrypt(data: Uint8Array): Uint8Array {
+ return tulipDecrypt(encryptionKey, data)[0];
+}
diff --git a/src/lib/server/crypto/sign.ts b/src/lib/server/crypto/sign.ts
new file mode 100644
index 0000000..71382d3
--- /dev/null
+++ b/src/lib/server/crypto/sign.ts
@@ -0,0 +1,25 @@
+import type { Hex } from "@noble/curves/abstract/utils";
+import { ed25519 } from "@noble/curves/ed25519";
+
+
+const privateKey = ed25519.utils.randomPrivateKey();
+const publicKey = ed25519.getPublicKey(privateKey);
+
+/**
+ * Signs a message with the server's private key.
+ *
+ * @param message Message to sign
+ */
+export function sign(message: Hex) {
+ return ed25519.sign(message, privateKey);
+}
+
+/**
+ * Verifies a signature with the server's public key.
+ *
+ * @param signature Signature to verify
+ * @param message Message to verify
+ */
+export function verify(signature: Hex, message: Hex) {
+ return ed25519.verify(signature, message, publicKey);
+}
diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts
new file mode 100644
index 0000000..8c628c4
--- /dev/null
+++ b/src/lib/server/db/index.ts
@@ -0,0 +1,13 @@
+import { env } from "$env/dynamic/private";
+import * as schema from "$lib/server/db/schema";
+import { Database } from "bun:sqlite";
+import { drizzle } from "drizzle-orm/bun-sqlite";
+
+
+if (! env.DATABASE_URL) {
+ throw new Error("DATABASE_URL is not set");
+}
+
+const client = new Database(env.DATABASE_URL);
+export const db = drizzle(client, { schema });
+export { schema };
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
new file mode 100644
index 0000000..ae07708
--- /dev/null
+++ b/src/lib/server/db/schema.ts
@@ -0,0 +1,132 @@
+import { randomId } from "$lib/utils/id";
+import { relations } from "drizzle-orm";
+import { blob, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
+
+
+export const users = sqliteTable("users", {
+ id: text("id").$default(randomId).primaryKey(),
+ username: text("username").notNull().unique(),
+ crv: text("crv").notNull(),
+ hak: text("hak").notNull(),
+ key: text("key").notNull(),
+ x25519PrivateKey: text("x25519_private_key"),
+ x25519PublicKey: text("x25519_public_key"),
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date()),
+});
+
+export const usersRelations = relations(users, ({ many }) => ({
+ sessions: many(sessions),
+ files: many(files),
+ chunks: many(chunks),
+}));
+
+export const sessions = sqliteTable("sessions", {
+ id: text("id").$default(randomId).primaryKey(),
+ userId: text("user_id").notNull(),
+ sessionId: text("session_id").notNull().unique(),
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date()),
+});
+
+export const sessionsRelations = relations(sessions, ({ one }) => ({
+ user: one(users, {
+ fields: [sessions.userId],
+ references: [users.id],
+ }),
+}));
+
+export const files = sqliteTable("files", {
+ id: text("id").$default(randomId).primaryKey(),
+ userId: text("user_id").notNull(),
+ key: text("key").notNull(),
+ cmac: text("cmac").notNull(),
+ attributes: text("attributes").notNull(),
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date()),
+});
+
+export const filesRelations = relations(files, ({ one, many }) => ({
+ user: one(users, {
+ fields: [files.userId],
+ references: [users.id],
+ }),
+ slot: one(slots, {
+ fields: [files.id],
+ references: [slots.fileId],
+ }),
+}));
+
+export const chunks = sqliteTable("chunks", {
+ id: text("id").$default(randomId).primaryKey(),
+ userId: text("user_id").references(() => users.id).notNull(),
+ blob: blob("blob").notNull(),
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date()),
+});
+
+export const chunksRelations = relations(chunks, ({ one }) => ({
+ user: one(users, {
+ fields: [chunks.userId],
+ references: [users.id],
+ }),
+ slots: one(slotChunks, {
+ fields: [chunks.id],
+ references: [slotChunks.chunkId],
+ }),
+}));
+
+export const vaults = sqliteTable("vaults", {
+ id: text("id").$default(randomId).primaryKey(),
+ userId: text("user_id").references(() => users.id).notNull(),
+ token: text("token").notNull(),
+ key: text("key").notNull(),
+ attributes: text("attributes").notNull(),
+ authEd25519PrivateKey: text("auth_ed25519_private_key").notNull(),
+ authEd25519PublicKey: text("auth_ed25519_public_key").notNull(),
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date()),
+});
+
+export const vaultsRelations = relations(vaults, ({ one, many }) => ({
+ user: one(users, {
+ fields: [vaults.userId],
+ references: [users.id],
+ }),
+ slots: many(slots),
+}));
+
+export const slots = sqliteTable("slots", {
+ id: text("id").$default(randomId).primaryKey(),
+ vaultId: text("vault_id").references(() => vaults.id).notNull(),
+ maxSize: integer("max_size").notNull(),
+ attributes: text("attributes").notNull(),
+ fileX25519PublicKey: text("file_x25519_public_key"),
+ fileId: text("file_id").references(() => files.id),
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().$default(() => new Date()),
+});
+
+export const slotsRelations = relations(slots, ({ one, many }) => ({
+ vault: one(vaults, {
+ fields: [slots.vaultId],
+ references: [vaults.id],
+ }),
+ file: one(files, {
+ fields: [slots.fileId],
+ references: [files.id],
+ }),
+ slotChunks: many(slotChunks),
+}));
+
+export const slotChunks = sqliteTable("slot_chunks", {
+ slotId: text("slot_id").references(() => slots.id).notNull(),
+ chunkId: text("chunk_id").references(() => chunks.id).notNull(),
+}, (table) => ({
+ pk: primaryKey({ columns: [table.slotId, table.chunkId] }),
+}));
+
+export const slotChunksRelations = relations(slotChunks, ({ one }) => ({
+ slot: one(slots, {
+ fields: [slotChunks.slotId],
+ references: [slots.id],
+ }),
+ chunk: one(chunks, {
+ fields: [slotChunks.chunkId],
+ references: [chunks.id],
+ }),
+}));
diff --git a/src/lib/server/index.ts b/src/lib/server/index.ts
new file mode 100644
index 0000000..9ae454b
--- /dev/null
+++ b/src/lib/server/index.ts
@@ -0,0 +1,4 @@
+import { randomClientValue } from "$lib/tulip/auth";
+
+
+export const serverRandomValue = randomClientValue();
diff --git a/src/lib/server/session.ts b/src/lib/server/session.ts
new file mode 100644
index 0000000..9c49683
--- /dev/null
+++ b/src/lib/server/session.ts
@@ -0,0 +1,57 @@
+import { sign, verify } from "$lib/server/crypto/sign";
+import { db, schema } from "$lib/server/db";
+import { decodeBase64Url, encodeBase64Url } from "$lib/utils/base64url";
+import { randomString } from "$lib/utils/id";
+import { utf8ToBytes } from "@noble/ciphers/utils";
+import type { RequestEvent } from "@sveltejs/kit";
+import { eq, type InferSelectModel } from "drizzle-orm";
+
+
+const SESSION_ID_LENGTH = 64;
+
+export async function createSession(user: InferSelectModel) {
+ const sessionId = randomString(SESSION_ID_LENGTH);
+ const signature = sign(utf8ToBytes(sessionId));
+
+ await db.insert(schema.sessions).values({
+ userId: user.id,
+ sessionId,
+ });
+
+ return `${sessionId}.${encodeBase64Url(signature)}`;
+}
+
+export async function getSessionFromRequest(event: RequestEvent): Promise | null> {
+ const sid = event.cookies.get("sid") ?? "";
+ if (! sid.includes(".")) {
+ return null;
+ }
+
+ const [message, encodedSignature] = sid.split(".");
+ if (typeof message == "undefined" || typeof encodedSignature == "undefined") {
+ throw new Error("Invalid session parameter");
+ }
+
+ const signature = decodeBase64Url(encodedSignature);
+ if (! verify(signature, utf8ToBytes(message))) {
+ return null;
+ }
+
+ const session = await db.query.sessions.findFirst({
+ where: eq(schema.sessions.sessionId, message),
+ });
+
+ return session ?? null;
+}
+
+export async function getSessionUser(session: InferSelectModel | null): Promise | null> {
+ if (session === null) {
+ return null;
+ }
+
+ const user = await db.query.users.findFirst({
+ where: eq(schema.users.id, session.userId),
+ });
+
+ return user ?? null;
+}
diff --git a/src/lib/server/types.ts b/src/lib/server/types.ts
new file mode 100644
index 0000000..e7c4bab
--- /dev/null
+++ b/src/lib/server/types.ts
@@ -0,0 +1,4 @@
+export interface SlotUploadTicket {
+ slotId: string;
+ timestamp: number;
+}
diff --git a/src/lib/state/upload.svelte.ts b/src/lib/state/upload.svelte.ts
new file mode 100644
index 0000000..6774461
--- /dev/null
+++ b/src/lib/state/upload.svelte.ts
@@ -0,0 +1 @@
+// export let
diff --git a/src/lib/trpc/client.ts b/src/lib/trpc/client.ts
new file mode 100644
index 0000000..4c5aac4
--- /dev/null
+++ b/src/lib/trpc/client.ts
@@ -0,0 +1,24 @@
+import type { Router } from "$lib/trpc/router";
+import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
+import { createTRPCClient, type TRPCClientInit } from "trpc-sveltekit";
+
+
+export type TrpcClient = ReturnType>;
+export type RouterInput = inferRouterInputs;
+export type RouterOutput = inferRouterOutputs;
+
+let browserClient: TrpcClient;
+
+export function trpc(init?: TRPCClientInit) {
+ const isBrowser = typeof window !== "undefined";
+ if (isBrowser && browserClient) {
+ return browserClient;
+ }
+
+ const client = createTRPCClient({ init });
+ if (isBrowser) {
+ browserClient = client;
+ }
+
+ return client;
+}
diff --git a/src/lib/trpc/context.ts b/src/lib/trpc/context.ts
new file mode 100644
index 0000000..ce27d6d
--- /dev/null
+++ b/src/lib/trpc/context.ts
@@ -0,0 +1,25 @@
+import { getSessionFromRequest, getSessionUser } from "$lib/server/session";
+import type { RequestEvent } from "@sveltejs/kit";
+
+
+async function getAuth(event: RequestEvent) {
+ const session = await getSessionFromRequest(event);
+ const user = await getSessionUser(session);
+ if (user === null) {
+ event.cookies.delete("sid", { path: "/" });
+ return null;
+ }
+
+ return {
+ user,
+ };
+}
+
+export async function createContext(event: RequestEvent) {
+ return {
+ event,
+ auth: await getAuth(event),
+ };
+}
+
+export type Context = Awaited>;
diff --git a/src/lib/trpc/middleware/auth.ts b/src/lib/trpc/middleware/auth.ts
new file mode 100644
index 0000000..43862db
--- /dev/null
+++ b/src/lib/trpc/middleware/auth.ts
@@ -0,0 +1,15 @@
+import { t } from "$lib/trpc/t";
+import { TRPCError } from "@trpc/server";
+
+
+export const authProcedure = t.procedure.use(async (opts) => {
+ if (opts.ctx.auth === null) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+
+ return opts.next({
+ ctx: {
+ auth: opts.ctx.auth,
+ },
+ });
+});
diff --git a/src/lib/trpc/router.ts b/src/lib/trpc/router.ts
new file mode 100644
index 0000000..02bb6b8
--- /dev/null
+++ b/src/lib/trpc/router.ts
@@ -0,0 +1,58 @@
+import { accessVault } from "$lib/trpc/routes/access-vault";
+import { auth } from "$lib/trpc/routes/auth";
+import { cleanStorage } from "$lib/trpc/routes/clean-storage";
+import { clearSlot } from "$lib/trpc/routes/clear-slot";
+import { createVault } from "$lib/trpc/routes/create-vault";
+import { crv } from "$lib/trpc/routes/crv";
+import { deleteFile } from "$lib/trpc/routes/delete-file";
+import { deleteVault } from "$lib/trpc/routes/delete-vault";
+import { file } from "$lib/trpc/routes/file";
+import { files } from "$lib/trpc/routes/files";
+import { login } from "$lib/trpc/routes/login";
+import { logout } from "$lib/trpc/routes/logout";
+import { register } from "$lib/trpc/routes/register";
+import { requestVault } from "$lib/trpc/routes/request-vault";
+import { slot } from "$lib/trpc/routes/slot";
+import { updateX25519 } from "$lib/trpc/routes/updateX25519";
+import { upload } from "$lib/trpc/routes/upload";
+import { uploadWithTicket } from "$lib/trpc/routes/upload-with-ticket";
+import { vaults } from "$lib/trpc/routes/vaults";
+import { t } from "$lib/trpc/t";
+
+
+export const router = t.router({
+ // Auth
+ auth,
+
+ // Registration
+ register,
+
+ // Login
+ crv,
+ login,
+
+ // Authenticated routes
+ updateX25519,
+ logout,
+ file,
+ files,
+ upload,
+ uploadWithTicket,
+ cleanStorage,
+ deleteFile,
+
+ // Vaults
+ createVault,
+ deleteVault,
+ vaults,
+ slot,
+ clearSlot,
+
+ // Public vault access
+ requestVault,
+ accessVault,
+});
+
+export const createCaller = t.createCallerFactory(router);
+
+export type Router = typeof router;
diff --git a/src/lib/trpc/routes/access-vault.ts b/src/lib/trpc/routes/access-vault.ts
new file mode 100644
index 0000000..3a6a17d
--- /dev/null
+++ b/src/lib/trpc/routes/access-vault.ts
@@ -0,0 +1,95 @@
+import { encrypt } from "$lib/server/crypto/encrypt";
+import { verify } from "$lib/server/crypto/sign";
+import { db, schema } from "$lib/server/db";
+import type { SlotUploadTicket } from "$lib/server/types";
+import { t } from "$lib/trpc/t";
+import { decodeBase64Url, encodeBase64Url } from "$lib/utils/base64url";
+import { utf8ToBytes } from "@noble/ciphers/utils";
+import { ed25519 } from "@noble/curves/ed25519";
+import { TRPCError } from "@trpc/server";
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ token: z.string(),
+ challenge: z.string(),
+ proof: z.string(),
+});
+
+const output = z.object({
+ x25519PublicKey: z.string(),
+ token: z.string(),
+ attributes: z.string(),
+ createdAt: z.string(),
+ slots: z.array(z.object({
+ maxSize: z.number(),
+ attributes: z.string(),
+ available: z.boolean(),
+ ticket: z.string(),
+ })),
+});
+
+export const accessVault = t.procedure
+ .input(input)
+ .output(output)
+ .query(async ({ input }) => {
+ const vault = await db.query.vaults.findFirst({
+ where: eq(schema.vaults.token, input.token),
+ with: {
+ user: true,
+ slots: true,
+ },
+ });
+ if (typeof vault == "undefined") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Vault not found",
+ });
+ }
+
+ const rawChallenge = decodeBase64Url(input.challenge);
+ const challenge = rawChallenge.subarray(0, 32);
+ const signature = rawChallenge.subarray(32);
+ if (! verify(signature, challenge)) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Foreign challenge",
+ });
+ }
+
+ const proof = decodeBase64Url(input.proof);
+ if (! ed25519.verify(proof, rawChallenge, decodeBase64Url(vault.authEd25519PublicKey))) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Challenge failed",
+ });
+ }
+
+ if (vault.user.x25519PublicKey === null) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Vault is not public",
+ });
+ }
+
+ return {
+ x25519PublicKey: vault.user.x25519PublicKey,
+ token: vault.token,
+ attributes: vault.attributes,
+ createdAt: vault.createdAt.toISOString(),
+ slots: vault.slots.map((slot) => {
+ const ticket = JSON.stringify({
+ slotId: slot.id,
+ timestamp: Math.floor(Date.now() / 1000),
+ } satisfies SlotUploadTicket);
+
+ return ({
+ maxSize: slot.maxSize,
+ attributes: slot.attributes,
+ available: slot.fileId === null,
+ ticket: encodeBase64Url(encrypt(utf8ToBytes(ticket))),
+ });
+ }),
+ };
+ });
diff --git a/src/lib/trpc/routes/auth.ts b/src/lib/trpc/routes/auth.ts
new file mode 100644
index 0000000..4efdb8d
--- /dev/null
+++ b/src/lib/trpc/routes/auth.ts
@@ -0,0 +1,29 @@
+import { getSessionFromRequest, getSessionUser } from "$lib/server/session";
+import { t } from "$lib/trpc/t";
+import { z } from "zod";
+
+
+const output = z.object({
+ u: z.string(),
+ key: z.string(),
+ x25519PrivateKey: z.string().nullable(),
+ x25519PublicKey: z.string().nullable(),
+}).nullable();
+
+export const auth = t.procedure
+ .output(output)
+ .query(async ({ ctx }) => {
+ const session = await getSessionFromRequest(ctx.event);
+ const user = await getSessionUser(session);
+ if (user === null) {
+ ctx.event.cookies.delete("sid", { path: "/" });
+ return null;
+ }
+
+ return {
+ u: user.username,
+ key: user.key,
+ x25519PrivateKey: user.x25519PrivateKey,
+ x25519PublicKey: user.x25519PublicKey,
+ };
+ });
diff --git a/src/lib/trpc/routes/clean-storage.ts b/src/lib/trpc/routes/clean-storage.ts
new file mode 100644
index 0000000..36e292d
--- /dev/null
+++ b/src/lib/trpc/routes/clean-storage.ts
@@ -0,0 +1,12 @@
+import { db, schema } from "$lib/server/db";
+import { authProcedure } from "$lib/trpc/middleware/auth";
+import { eq } from "drizzle-orm";
+
+
+export const cleanStorage = authProcedure
+ .mutation(async ({ input, ctx }) => {
+ await db.transaction(async (tx) => {
+ await tx.delete(schema.chunks).where(eq(schema.chunks.userId, ctx.auth.user.id));
+ await tx.delete(schema.files).where(eq(schema.files.userId, ctx.auth.user.id));
+ });
+ });
diff --git a/src/lib/trpc/routes/clear-slot.ts b/src/lib/trpc/routes/clear-slot.ts
new file mode 100644
index 0000000..9b64b3e
--- /dev/null
+++ b/src/lib/trpc/routes/clear-slot.ts
@@ -0,0 +1,49 @@
+import { db, schema } from "$lib/server/db";
+import { authProcedure } from "$lib/trpc/middleware/auth";
+import { TRPCError } from "@trpc/server";
+import { and, eq, inArray } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ id: z.string().max(20),
+});
+
+export const clearSlot = authProcedure
+ .input(input)
+ .mutation(async ({ input, ctx }) => {
+ const [slot] = await db.select()
+ .from(schema.slots)
+ .innerJoin(schema.vaults, eq(schema.vaults.id, schema.slots.vaultId))
+ .where(and(
+ eq(schema.vaults.userId, ctx.auth.user.id),
+ eq(schema.slots.id, input.id),
+ ))
+ .limit(1);
+ if (typeof slot == "undefined") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Slot not found",
+ });
+ }
+
+ await db.transaction(async (tx) => {
+ if (slot.slots.fileId !== null) {
+ const slotChunks = await tx.query.slotChunks.findMany({
+ where: eq(schema.slotChunks.slotId, slot.slots.id),
+ });
+ const chunkIds = slotChunks.map((chunk) => chunk.chunkId);
+
+ await tx.delete(schema.slotChunks).where(eq(schema.slotChunks.slotId, slot.slots.id));
+ await tx.delete(schema.chunks).where(inArray(schema.chunks.id, chunkIds));
+ await tx.delete(schema.files).where(eq(schema.files.id, slot.slots.fileId));
+ }
+
+ await tx.update(schema.slots)
+ .set({
+ fileId: null,
+ fileX25519PublicKey: null,
+ })
+ .where(eq(schema.slots.id, slot.slots.id));
+ });
+ });
diff --git a/src/lib/trpc/routes/create-vault.ts b/src/lib/trpc/routes/create-vault.ts
new file mode 100644
index 0000000..a8ad706
--- /dev/null
+++ b/src/lib/trpc/routes/create-vault.ts
@@ -0,0 +1,67 @@
+import { db, schema } from "$lib/server/db";
+import { authProcedure } from "$lib/trpc/middleware/auth";
+import { randomString } from "$lib/utils/id";
+import { TRPCError } from "@trpc/server";
+import type { InferSelectModel } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ key: z.string(),
+ attributes: z.string(),
+ authEd25519PrivateKey: z.string().max(255),
+ authEd25519PublicKey: z.string().max(255),
+ slots: z.array(z.object({
+ maxSize: z.number().positive(),
+ attributes: z.string(),
+ })).min(1),
+});
+
+const output = z.object({
+ id: z.string(),
+ token: z.string(),
+});
+
+export const createVault = authProcedure
+ .input(input)
+ .output(output)
+ .mutation(async ({ input, ctx }) => {
+ let vault: InferSelectModel | undefined;
+
+ await db.transaction(async (tx) => {
+ [vault] = await tx.insert(schema.vaults).values({
+ userId: ctx.auth.user.id,
+ token: randomString(64),
+ authEd25519PrivateKey: input.authEd25519PrivateKey,
+ authEd25519PublicKey: input.authEd25519PublicKey,
+ key: input.key,
+ attributes: input.attributes,
+ }).returning();
+ if (typeof vault == "undefined") {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create vault",
+ });
+ }
+
+ for (const slots of input.slots) {
+ await tx.insert(schema.slots).values({
+ vaultId: vault.id,
+ maxSize: slots.maxSize,
+ attributes: slots.attributes,
+ });
+ }
+ });
+
+ if (typeof vault == "undefined") {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create vault",
+ });
+ }
+
+ return {
+ id: vault.id,
+ token: vault.token,
+ };
+ });
diff --git a/src/lib/trpc/routes/crv.ts b/src/lib/trpc/routes/crv.ts
new file mode 100644
index 0000000..bbc94f0
--- /dev/null
+++ b/src/lib/trpc/routes/crv.ts
@@ -0,0 +1,35 @@
+import { serverRandomValue } from "$lib/server";
+import { db, schema } from "$lib/server/db";
+import { t } from "$lib/trpc/t";
+import { generateSalt } from "$lib/tulip/auth";
+import { decodeBase64Url, encodeBase64Url } from "$lib/utils/base64url";
+import { utf8ToBytes } from "@noble/ciphers/utils";
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ u: z.string().max(40),
+});
+
+const output = z.object({
+ salt: z.string(),
+});
+
+export const crv = t.procedure
+ .input(input)
+ .output(output)
+ .query(async ({ input }) => {
+ const user = await db.query.users.findFirst({
+ where: eq(schema.users.username, input.u),
+ });
+ await new Promise(resolve => setTimeout(resolve, Math.random() * 100));
+
+ const salt = typeof user != "undefined"
+ ? generateSalt(decodeBase64Url(user.crv))
+ : generateSalt(serverRandomValue, utf8ToBytes(input.u));
+
+ return {
+ salt: encodeBase64Url(salt),
+ };
+ });
diff --git a/src/lib/trpc/routes/delete-file.ts b/src/lib/trpc/routes/delete-file.ts
new file mode 100644
index 0000000..e20e140
--- /dev/null
+++ b/src/lib/trpc/routes/delete-file.ts
@@ -0,0 +1,41 @@
+import { db, schema } from "$lib/server/db";
+import { authProcedure } from "$lib/trpc/middleware/auth";
+import { TRPCError } from "@trpc/server";
+import { and, eq, inArray } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ id: z.string().max(20),
+ chunkIds: z.array(z.string().max(20)),
+});
+
+export const deleteFile = authProcedure
+ .input(input)
+ .mutation(async ({ input, ctx }) => {
+ const file = await db.query.files.findFirst({
+ where: and(
+ eq(schema.files.userId, ctx.auth.user.id),
+ eq(schema.files.id, input.id),
+ ),
+ });
+
+ if (typeof file == "undefined") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "File not found",
+ });
+ }
+
+ await db.transaction(async (tx) => {
+ await tx.delete(schema.chunks).where(and(
+ eq(schema.chunks.userId, ctx.auth.user.id),
+ inArray(schema.chunks.id, input.chunkIds),
+ ));
+ await tx.update(schema.slots).set({
+ fileX25519PublicKey: null,
+ fileId: null,
+ }).where(eq(schema.slots.fileId, file.id));
+ await tx.delete(schema.files).where(eq(schema.files.id, file.id));
+ });
+ });
diff --git a/src/lib/trpc/routes/delete-vault.ts b/src/lib/trpc/routes/delete-vault.ts
new file mode 100644
index 0000000..b925cf5
--- /dev/null
+++ b/src/lib/trpc/routes/delete-vault.ts
@@ -0,0 +1,47 @@
+import { db, schema } from "$lib/server/db";
+import { authProcedure } from "$lib/trpc/middleware/auth";
+import { TRPCError } from "@trpc/server";
+import { and, eq, inArray } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ id: z.string().max(20),
+});
+
+export const deleteVault = authProcedure
+ .input(input)
+ .mutation(async ({ input, ctx }) => {
+ const vault = await db.query.vaults.findFirst({
+ where: and(
+ eq(schema.vaults.userId, ctx.auth.user.id),
+ eq(schema.vaults.id, input.id),
+ ),
+ with: {
+ slots: {
+ with: {
+ slotChunks: true,
+ },
+ },
+ },
+ });
+
+ if (typeof vault == "undefined") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Vault not found",
+ });
+ }
+
+ await db.transaction(async (tx) => {
+ const slotIds = vault.slots.map((slot) => slot.id);
+ const fileIds = vault.slots.filter((slot) => slot.fileId !== null).map((slot) => slot.fileId) as string[];
+ const chunkIds = vault.slots.flatMap((slot) => slot.slotChunks.map((chunk) => chunk.chunkId));
+
+ await tx.delete(schema.slotChunks).where(inArray(schema.slotChunks.slotId, slotIds));
+ await tx.delete(schema.chunks).where(inArray(schema.chunks.id, chunkIds));
+ await tx.delete(schema.slots).where(eq(schema.slots.vaultId, vault.id));
+ await tx.delete(schema.files).where(inArray(schema.files.id, fileIds));
+ await tx.delete(schema.vaults).where(eq(schema.vaults.id, vault.id));
+ });
+ });
diff --git a/src/lib/trpc/routes/file.ts b/src/lib/trpc/routes/file.ts
new file mode 100644
index 0000000..3ad791a
--- /dev/null
+++ b/src/lib/trpc/routes/file.ts
@@ -0,0 +1,40 @@
+import { db, schema } from "$lib/server/db";
+import { authProcedure } from "$lib/trpc/middleware/auth";
+import { and, eq } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ id: z.string(),
+});
+
+const output = z.object({
+ id: z.string(),
+ key: z.string(),
+ cmac: z.string(),
+ attributes: z.string(),
+ createdAt: z.string(),
+}).optional();
+
+export const file = authProcedure
+ .input(input)
+ .output(output)
+ .query(async ({ input, ctx }) => {
+ const file = await db.query.files.findFirst({
+ where: and(
+ eq(schema.files.userId, ctx.auth.user.id),
+ eq(schema.files.id, input.id),
+ ),
+ });
+ if (typeof file == "undefined") {
+ return undefined;
+ }
+
+ return {
+ id: file.id,
+ key: file.key,
+ cmac: file.cmac,
+ attributes: file.attributes,
+ createdAt: file.createdAt.toISOString(),
+ };
+ });
diff --git a/src/lib/trpc/routes/files.ts b/src/lib/trpc/routes/files.ts
new file mode 100644
index 0000000..e6364d9
--- /dev/null
+++ b/src/lib/trpc/routes/files.ts
@@ -0,0 +1,36 @@
+import { db, schema } from "$lib/server/db";
+import { authProcedure } from "$lib/trpc/middleware/auth";
+import { and, eq, notExists } from "drizzle-orm";
+import { z } from "zod";
+
+
+const output = z.object({
+ files: z.array(z.object({
+ id: z.string(),
+ key: z.string(),
+ cmac: z.string(),
+ attributes: z.string(),
+ createdAt: z.string(),
+ })),
+});
+
+export const files = authProcedure
+ .output(output)
+ .query(async ({ ctx }) => {
+ const files = await db.query.files.findMany({
+ where: and(
+ eq(schema.files.userId, ctx.auth.user.id),
+ notExists(db.select().from(schema.slots).where(eq(schema.slots.fileId, schema.files.id))),
+ ),
+ });
+
+ return {
+ files: files.map(file => ({
+ id: file.id,
+ key: file.key,
+ cmac: file.cmac,
+ attributes: file.attributes,
+ createdAt: file.createdAt.toISOString(),
+ })),
+ };
+ });
diff --git a/src/lib/trpc/routes/login.ts b/src/lib/trpc/routes/login.ts
new file mode 100644
index 0000000..03b4313
--- /dev/null
+++ b/src/lib/trpc/routes/login.ts
@@ -0,0 +1,58 @@
+import { db, schema } from "$lib/server/db";
+import { createSession } from "$lib/server/session";
+import { t } from "$lib/trpc/t";
+import { generateHashedAuthenticationKey } from "$lib/tulip/auth";
+import { decodeBase64Url, encodeBase64Url } from "$lib/utils/base64url";
+import { TRPCError } from "@trpc/server";
+import { and, eq } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ u: z.string().max(40),
+ ak: z.string().max(255),
+});
+
+const output = z.object({
+ u: z.string(),
+ key: z.string(),
+ x25519PrivateKey: z.string().nullable(),
+ x25519PublicKey: z.string().nullable(),
+});
+
+export const login = t.procedure
+ .input(input)
+ .output(output)
+ .mutation(async ({ input, ctx }) => {
+ const hashedAuthenticationKey = generateHashedAuthenticationKey(decodeBase64Url(input.ak));
+ const user = await db.query.users.findFirst({
+ where: and(
+ eq(schema.users.username, input.u),
+ eq(schema.users.hak, encodeBase64Url(hashedAuthenticationKey)),
+ ),
+ });
+
+ if (typeof user == "undefined") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Cannot find user",
+ });
+ }
+
+ const sessionId = await createSession(user);
+
+ ctx.event.cookies.set("sid", sessionId, {
+ path: "/",
+ httpOnly: true,
+ sameSite: "strict",
+ maxAge: 60 * 60 * 24 * 30,
+ secure: process.env.NODE_ENV === "production",
+ });
+
+ return {
+ u: user.username,
+ key: user.key,
+ x25519PrivateKey: user.x25519PrivateKey,
+ x25519PublicKey: user.x25519PublicKey,
+ };
+ });
diff --git a/src/lib/trpc/routes/logout.ts b/src/lib/trpc/routes/logout.ts
new file mode 100644
index 0000000..b58cac6
--- /dev/null
+++ b/src/lib/trpc/routes/logout.ts
@@ -0,0 +1,7 @@
+import { authProcedure } from "$lib/trpc/middleware/auth";
+
+
+export const logout = authProcedure
+ .mutation(async ({ ctx }) => {
+ ctx.event.cookies.delete("sid", { path: "/" });
+ });
diff --git a/src/lib/trpc/routes/register.ts b/src/lib/trpc/routes/register.ts
new file mode 100644
index 0000000..36b6d42
--- /dev/null
+++ b/src/lib/trpc/routes/register.ts
@@ -0,0 +1,61 @@
+import { db, schema } from "$lib/server/db";
+import { t } from "$lib/trpc/t";
+import { TRPCError } from "@trpc/server";
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ u: z.string()
+ .min(3, "Your username must be at least 3 characters")
+ .max(40, "Your username cannot be longer than 40 characters"),
+ crv: z.string().min(1).max(255),
+ hak: z.string().min(1).max(255),
+ key: z.string().min(1).max(255),
+ x25519PrivateKey: z.string().min(1).max(255),
+ x25519PublicKey: z.string().min(1).max(255),
+});
+
+const ALLOWED_USERNAMES = new Set(["admin", "felix", "jacob420"]);
+
+export const register = t.procedure
+ .input(input)
+ .mutation(async ({ input }) => {
+ const username = input.u.trim();
+
+ if (process.env.NODE_ENV === "production" && ! ALLOWED_USERNAMES.has(username)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid username",
+ });
+ }
+
+ // Check if user exists
+ const existing = await db.query.users.findFirst({
+ where: eq(schema.users.username, username),
+ });
+
+ if (typeof existing != "undefined") {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "User already exists",
+ });
+ }
+
+ // Create user
+ const [user] = await db.insert(schema.users).values({
+ username,
+ crv: input.crv,
+ hak: input.hak,
+ key: input.key,
+ x25519PrivateKey: input.x25519PrivateKey,
+ x25519PublicKey: input.x25519PublicKey,
+ }).returning();
+
+ if (typeof user == "undefined") {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Cannot create user",
+ });
+ }
+ });
diff --git a/src/lib/trpc/routes/request-vault.ts b/src/lib/trpc/routes/request-vault.ts
new file mode 100644
index 0000000..8a5332f
--- /dev/null
+++ b/src/lib/trpc/routes/request-vault.ts
@@ -0,0 +1,48 @@
+import { sign } from "$lib/server/crypto/sign";
+import { db, schema } from "$lib/server/db";
+import { t } from "$lib/trpc/t";
+import { concat } from "$lib/tulip/utils/buffer";
+import { encodeBase64Url } from "$lib/utils/base64url";
+import { randomBytes } from "@noble/ciphers/webcrypto";
+import { TRPCError } from "@trpc/server";
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ token: z.string(),
+});
+
+const output = z.object({
+ token: z.string(),
+ authkey: z.string(),
+ challenge: z.string(),
+});
+
+export const requestVault = t.procedure
+ .input(input)
+ .output(output)
+ .query(async ({ input }) => {
+ const vault = await db.query.vaults.findFirst({
+ where: eq(schema.vaults.token, input.token),
+ });
+ if (typeof vault == "undefined") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Vault not found",
+ });
+ }
+
+ // TODO: Just creating a random challenge is not secure enough because this makes us vulnerable to replay attacks
+ // An attacker could simply send the same challenge and proof to the access endpoint and get access to the vault
+ // The challenge should probably contain a timestamp and the token of the vault
+ // Note: but then we can maybe just use XChaCha20-Poly1305 instead of ED25519 for the challenge lol
+ const challenge = randomBytes(32);
+ const signature = sign(challenge);
+
+ return {
+ token: vault.token,
+ authkey: vault.authEd25519PrivateKey,
+ challenge: encodeBase64Url(concat(challenge, signature)),
+ };
+ });
diff --git a/src/lib/trpc/routes/slot.ts b/src/lib/trpc/routes/slot.ts
new file mode 100644
index 0000000..adb07db
--- /dev/null
+++ b/src/lib/trpc/routes/slot.ts
@@ -0,0 +1,44 @@
+import { db, schema } from "$lib/server/db";
+import { authProcedure } from "$lib/trpc/middleware/auth";
+import { and, eq } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ id: z.string(),
+});
+
+const output = z.object({
+ id: z.string(),
+ maxSize: z.number(),
+ attributes: z.string(),
+ fileX25519PublicKey: z.string().nullable(),
+ fileId: z.string().nullable(),
+ createdAt: z.string(),
+}).optional();
+
+export const slot = authProcedure
+ .input(input)
+ .output(output)
+ .query(async ({ input, ctx }) => {
+ const [slot] = await db.select()
+ .from(schema.slots)
+ .innerJoin(schema.vaults, eq(schema.vaults.id, schema.slots.vaultId))
+ .where(and(
+ eq(schema.vaults.userId, ctx.auth.user.id),
+ eq(schema.slots.id, input.id),
+ ))
+ .limit(1);
+ if (typeof slot == "undefined") {
+ return undefined;
+ }
+
+ return {
+ id: slot.slots.id,
+ maxSize: slot.slots.maxSize,
+ attributes: slot.slots.attributes,
+ fileX25519PublicKey: slot.slots.fileX25519PublicKey,
+ fileId: slot.slots.fileId,
+ createdAt: slot.slots.createdAt.toISOString(),
+ };
+ });
diff --git a/src/lib/trpc/routes/updateX25519.ts b/src/lib/trpc/routes/updateX25519.ts
new file mode 100644
index 0000000..196be6d
--- /dev/null
+++ b/src/lib/trpc/routes/updateX25519.ts
@@ -0,0 +1,21 @@
+import { db, schema } from "$lib/server/db";
+import { authProcedure } from "$lib/trpc/middleware/auth";
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ x25519PrivateKey: z.string().min(1).max(255),
+ x25519PublicKey: z.string().min(1).max(255),
+});
+
+export const updateX25519 = authProcedure
+ .input(input)
+ .mutation(async ({ input, ctx }) => {
+ await db.update(schema.users)
+ .set({
+ x25519PrivateKey: input.x25519PrivateKey,
+ x25519PublicKey: input.x25519PublicKey,
+ })
+ .where(eq(schema.users.id, ctx.auth.user.id)).returning();
+ });
diff --git a/src/lib/trpc/routes/upload-with-ticket.ts b/src/lib/trpc/routes/upload-with-ticket.ts
new file mode 100644
index 0000000..a67ca67
--- /dev/null
+++ b/src/lib/trpc/routes/upload-with-ticket.ts
@@ -0,0 +1,67 @@
+import { decrypt } from "$lib/server/crypto/encrypt";
+import { db, schema } from "$lib/server/db";
+import type { SlotUploadTicket } from "$lib/server/types";
+import { t } from "$lib/trpc/t";
+import { decodeBase64Url } from "$lib/utils/base64url";
+import { bytesToUtf8 } from "@noble/ciphers/utils";
+import { error } from "@sveltejs/kit";
+import { TRPCError } from "@trpc/server";
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+
+
+const input = z.object({
+ ticket: z.string().max(255),
+ x25519PublicKey: z.string().max(255),
+ key: z.string().max(255),
+ cmac: z.string().max(255),
+ attributes: z.string(),
+});
+
+export const uploadWithTicket = t.procedure
+ .input(input)
+ .mutation(async ({ input }) => {
+ const decrypted = decrypt(decodeBase64Url(input.ticket));
+ const ticket: SlotUploadTicket = JSON.parse(bytesToUtf8(decrypted));
+
+ if (ticket.timestamp < Date.now() / 1000 - 60 * 60) {
+ // Ticket expired
+ return error(422, "Ticket expired");
+ }
+
+ const slot = await db.query.slots.findFirst({
+ where: eq(schema.slots.id, ticket.slotId),
+ with: {
+ vault: true,
+ },
+ });
+ if (typeof slot == "undefined") {
+ return error(422, "Invalid ticket");
+ }
+
+ if (slot.fileId !== null) {
+ return error(422, "Slot not available");
+ }
+
+ await db.transaction(async (tx) => {
+ const [file] = await tx.insert(schema.files).values({
+ userId: slot.vault.userId,
+ key: input.key,
+ cmac: input.cmac,
+ attributes: input.attributes,
+ }).returning();
+ if (typeof file == "undefined") {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create file",
+ });
+ }
+
+ await tx.update(schema.slots)
+ .set({
+ fileX25519PublicKey: input.x25519PublicKey,
+ fileId: file.id,
+ })
+ .where(eq(schema.slots.id, slot.id));
+ });
+ });
diff --git a/src/lib/trpc/routes/upload.ts b/src/lib/trpc/routes/upload.ts
new file mode 100644
index 0000000..c6a42e1
--- /dev/null
+++ b/src/lib/trpc/routes/upload.ts
@@ -0,0 +1,21 @@
+import { db, schema } from "$lib/server/db";
+import { authProcedure } from "$lib/trpc/middleware/auth";
+import { z } from "zod";
+
+
+const input = z.object({
+ key: z.string().max(255),
+ cmac: z.string().max(255),
+ attributes: z.string(),
+});
+
+export const upload = authProcedure
+ .input(input)
+ .mutation(async ({ input, ctx }) => {
+ await db.insert(schema.files).values({
+ userId: ctx.auth.user.id,
+ key: input.key,
+ cmac: input.cmac,
+ attributes: input.attributes,
+ });
+ });
diff --git a/src/lib/trpc/routes/vaults.ts b/src/lib/trpc/routes/vaults.ts
new file mode 100644
index 0000000..ecd98e0
--- /dev/null
+++ b/src/lib/trpc/routes/vaults.ts
@@ -0,0 +1,73 @@
+import { db, schema } from "$lib/server/db";
+import { authProcedure } from "$lib/trpc/middleware/auth";
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+
+
+const output = z.object({
+ vaults: z.array(z.object({
+ id: z.string(),
+ token: z.string(),
+ key: z.string(),
+ attributes: z.string(),
+ authEd25519PrivateKey: z.string(),
+ authEd25519PublicKey: z.string(),
+ createdAt: z.string(),
+ slots: z.array(z.object({
+ id: z.string(),
+ maxSize: z.number(),
+ attributes: z.string(),
+ fileX25519PublicKey: z.string().nullable(),
+ file: z.object({
+ id: z.string(),
+ key: z.string(),
+ cmac: z.string(),
+ attributes: z.string(),
+ createdAt: z.string(),
+ }).nullable(),
+ createdAt: z.string(),
+ })),
+ })),
+});
+
+export const vaults = authProcedure
+ .output(output)
+ .query(async ({ ctx }) => {
+ const vaults = await db.query.vaults.findMany({
+ where: eq(schema.vaults.userId, ctx.auth.user.id),
+ with: {
+ slots: {
+ with: {
+ file: true,
+ },
+ },
+ },
+ });
+
+ return {
+ vaults: vaults.map(vault => ({
+ id: vault.id,
+ token: vault.token,
+ key: vault.key,
+ attributes: vault.attributes,
+ authEd25519PrivateKey: vault.authEd25519PrivateKey,
+ authEd25519PublicKey: vault.authEd25519PublicKey,
+ createdAt: vault.createdAt.toISOString(),
+ slots: vault.slots.map(slot => ({
+ id: slot.id,
+ maxSize: slot.maxSize,
+ attributes: slot.attributes,
+ fileX25519PublicKey: slot.fileX25519PublicKey,
+ fileId: slot.fileId,
+ file: slot.file !== null ? {
+ id: slot.file.id,
+ key: slot.file.key,
+ cmac: slot.file.cmac,
+ attributes: slot.file.attributes,
+ createdAt: slot.file.createdAt.toISOString(),
+ } : null,
+ createdAt: slot.createdAt.toISOString(),
+ })),
+ })),
+ };
+ });
diff --git a/src/lib/trpc/t.ts b/src/lib/trpc/t.ts
new file mode 100644
index 0000000..c3fbb6d
--- /dev/null
+++ b/src/lib/trpc/t.ts
@@ -0,0 +1,14 @@
+import type { Context } from "$lib/trpc/context";
+import { initTRPC } from "@trpc/server";
+
+
+export const t = initTRPC.context().create({
+ errorFormatter: ({ shape, error }) => ({
+ code: shape.code,
+ message: error.message,
+ data: {
+ ...shape.data,
+ stack: process.env.NODE_ENV === "development" ? error.stack : undefined,
+ },
+ }),
+});
diff --git a/src/lib/tulip/auth.ts b/src/lib/tulip/auth.ts
new file mode 100644
index 0000000..0b6d137
--- /dev/null
+++ b/src/lib/tulip/auth.ts
@@ -0,0 +1,77 @@
+import { concat } from "$lib/tulip/utils/buffer";
+import { utf8ToBytes } from "@noble/ciphers/utils";
+import { randomBytes } from "@noble/ciphers/webcrypto";
+import { pbkdf2 } from "@noble/hashes/pbkdf2";
+import { sha512 } from "@noble/hashes/sha2";
+
+
+// Salt values
+// Keep them in sync with the backend
+const SALT_PREFIX_MAGIC = "tulip-";
+const SALT_PREFIX_LENGTH = 224;
+const SALT_PREFIX = SALT_PREFIX_MAGIC.padEnd(SALT_PREFIX_LENGTH, "#");
+const SALT_PREFIX_BYTES = utf8ToBytes(SALT_PREFIX);
+
+// Key lengths
+const CLIENT_RANDOM_VALUE_LENGTH = 32;
+
+// The derived key length
+const DERIVED_KEY_LENGTH = 64;
+
+// The number of iterations for the derived key
+// See https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
+const DERIVED_KEY_ITERATIONS = 210_000;
+
+// Take only the first 32 bytes of the hashed authentication key
+const HASHED_AUTHENTICATION_KEY_LENGTH = 32;
+
+/**
+ * Generates a client random value.
+ * The client random value will be used to generate the salt.
+ */
+export function randomClientValue(): Uint8Array {
+ return randomBytes(CLIENT_RANDOM_VALUE_LENGTH);
+}
+
+/**
+ * Generates a salt given a client random value.
+ * The salt together with the password will be used for the password-based key derivation function.
+ *
+ * @param crv Client random value
+ * @param prefix Optional prefix
+ */
+export function generateSalt(crv: Uint8Array, prefix?: Uint8Array): Uint8Array {
+ return sha512(concat(prefix, SALT_PREFIX_BYTES.subarray(0, SALT_PREFIX_LENGTH - (prefix?.length ?? 0)), crv));
+}
+
+/**
+ * Generates the authentication key and the encryption key from the user password and a salt.
+ * The derived authentication key is being used to authenticate login requests.
+ * The derived encryption key is being used to encrypt the master key.
+ *
+ * @param password The user password
+ * @param salt The salt generated by `generateSalt`
+ */
+export function generateDerivedKeys(
+ password: Uint8Array,
+ salt: Uint8Array,
+): [derivedEncryptionKey: Uint8Array, derivedAuthenticationKey: Uint8Array] {
+ const derivedKey = pbkdf2(sha512, password, salt, { c: DERIVED_KEY_ITERATIONS, dkLen: DERIVED_KEY_LENGTH });
+ const derivedEncryptionKey = derivedKey.subarray(0, derivedKey.length / 2);
+ const derivedAuthenticationKey = derivedKey.subarray(derivedKey.length / 2);
+
+ return [
+ derivedEncryptionKey,
+ derivedAuthenticationKey,
+ ];
+}
+
+/**
+ * Generates the hashed authentication key by taking a couple of bytes of the sha512'd derived authentication key.
+ * The hashed authentication key is stored on the server, in order to authenticate login requests.
+ *
+ * @param derivedAuthenticationKey Derived authentication key generated by `generateDerivedKeys`
+ */
+export function generateHashedAuthenticationKey(derivedAuthenticationKey: Uint8Array): Uint8Array {
+ return sha512(derivedAuthenticationKey).subarray(0, HASHED_AUTHENTICATION_KEY_LENGTH);
+}
diff --git a/src/lib/tulip/files.ts b/src/lib/tulip/files.ts
new file mode 100644
index 0000000..73d4f71
--- /dev/null
+++ b/src/lib/tulip/files.ts
@@ -0,0 +1,110 @@
+import { sha256 } from "@noble/hashes/sha2";
+
+
+/**
+ * Updates a condensed MAC with the given MAC and authentication tag.
+ *
+ * @param mac
+ * @param authenticationTag
+ */
+export function updateCondensedMac(mac: Uint8Array, authenticationTag: Uint8Array): Uint8Array {
+ const copy = new Uint8Array(mac);
+
+ // XOR the auth tag into the condensed MAC
+ for (let i = 0; i < copy.length; i++) {
+ copy[i]! ^= authenticationTag[i % authenticationTag.length]!;
+ }
+
+ return sha256(copy).slice(0, 16);
+}
+
+/**
+ * Obfuscates the given file key using the given nonce and the given condensed MAC.
+ *
+ * Based on the following pseudocode:
+ * Obfuscated File Key = [
+ * File Key[0] ⊕ Nonce[0],
+ * File Key[1] ⊕ Nonce[1],
+ * File Key[2] ⊕ Nonce[2],
+ * File Key[3] ⊕ Nonce[3],
+ * File Key[4] ⊕ Nonce[4],
+ * File Key[5] ⊕ Nonce[5],
+ * File Key[6] ⊕ Nonce[6],
+ * File Key[7] ⊕ Nonce[7],
+ * File Key[8] ⊕ Nonce[8],
+ * File Key[9] ⊕ Nonce[9],
+ * File Key[10] ⊕ Nonce[10],
+ * File Key[11] ⊕ Nonce[11],
+ * File Key[12] ⊕ Condensed MAC[0] ⊕ Condensed MAC[1],
+ * File Key[13] ⊕ Condensed MAC[2] ⊕ Condensed MAC[3],
+ * File Key[14] ⊕ Condensed MAC[4] ⊕ Condensed MAC[5],
+ * File Key[15] ⊕ Condensed MAC[6] ⊕ Condensed MAC[7],
+ * Nonce[0],
+ * Nonce[1],
+ * Nonce[2],
+ * Nonce[3],
+ * Nonce[4],
+ * Nonce[5],
+ * Nonce[6],
+ * Nonce[7],
+ * Nonce[8],
+ * Nonce[9],
+ * Nonce[10],
+ * Nonce[11],
+ * Condensed MAC[0] ⊕ Condensed MAC[1],
+ * Condensed MAC[2] ⊕ Condensed MAC[3],
+ * Condensed MAC[4] ⊕ Condensed MAC[5],
+ * Condensed MAC[6] ⊕ Condensed MAC[7],
+ * ]
+ *
+ * @param fileKey
+ * @param nonce
+ * @param condensedMac
+ */
+export function obfuscateFileKey(fileKey: Uint8Array, nonce: Uint8Array, condensedMac: Uint8Array): Uint8Array {
+ const obfuscated = new Uint8Array(64);
+
+ // First 24 bytes of the file key are XORed with the nonce
+ for (let i = 0; i < 24; i++) {
+ obfuscated[i] = fileKey[i]! ^ nonce[i]!;
+ }
+
+ // Next 8 bytes of the file key are XORed with the first 8 bytes of the condensed MAC
+ for (let i = 24; i < 32; i++) {
+ const cmacIndex1 = (i - 24) * 2;
+ const cmacIndex2 = cmacIndex1 + 1;
+
+ obfuscated[i] = fileKey[i]! ^ condensedMac[cmacIndex1]! ^ condensedMac[cmacIndex2]!;
+ }
+
+
+ // Next 24 bytes of the obfuscated file key are just the nonce
+ for (let i = 32; i < 56; i++) {
+ obfuscated[i] = nonce[i - 32]!;
+ }
+
+ // Last 8 bytes of the obfuscated file key the XORed using the condensed MAC
+ for (let i = 56; i < 64; i++) {
+ const cmacIndex1 = (i - 24) * 2;
+ const cmacIndex2 = cmacIndex1 + 1;
+
+ obfuscated[i] = condensedMac[cmacIndex1]! ^ condensedMac[cmacIndex2]!;
+ }
+
+ return obfuscated;
+}
+
+/**
+ * Reverts the file key obfuscation and returns the original file key.
+ *
+ * @param obfuscatedFileKey Obfuscated file key
+ * @param nonce Nonce used for the encryption
+ * @param condensedMac Condensed MAC
+ */
+export function unobfuscateFileKey(
+ obfuscatedFileKey: Uint8Array,
+ nonce: Uint8Array,
+ condensedMac: Uint8Array,
+): Uint8Array {
+ return obfuscateFileKey(obfuscatedFileKey, nonce, condensedMac).slice(0, 32);
+}
diff --git a/src/lib/tulip/index.ts b/src/lib/tulip/index.ts
new file mode 100644
index 0000000..be15509
--- /dev/null
+++ b/src/lib/tulip/index.ts
@@ -0,0 +1,84 @@
+import { concat } from "$lib/tulip/utils/buffer";
+import { xchacha20poly1305 } from "@noble/ciphers/chacha";
+import { randomBytes } from "@noble/ciphers/webcrypto";
+
+
+// Key length for XChaCha20-Poly1305
+const KEY_LENGTH = 32;
+
+// Nonce length for XChaCha20-Poly1305
+const NONCE_LENGTH = 24;
+
+/**
+ * Generates a random key for XChaCha20-Poly1305.
+ */
+export function randomKey(): Uint8Array {
+ return randomBytes(KEY_LENGTH);
+}
+
+/**
+ * Generates a nonce.
+ */
+export function randomNonce(): Uint8Array {
+ return randomBytes(NONCE_LENGTH);
+}
+
+/**
+ * Encrypts the given data using XChaCha20-Poly1305.
+ *
+ * @param key Encryption key
+ * @param data Plaintext data to encrypt
+ * @param nonce Nonce for the encryption. If not provided, a random nonce will be generated.
+ */
+export function encrypt(
+ key: Uint8Array,
+ data: Uint8Array,
+ nonce: Uint8Array = randomNonce(),
+): Uint8Array {
+ const encrypted = xchacha20poly1305(key, nonce).encrypt(data);
+
+ return concat(nonce, encrypted);
+}
+
+/**
+ * Decrypts the given XChaCha20-Poly1305 encrypted data.
+ *
+ * @param key Encryption key
+ * @param data Data to decrypt
+ */
+export function decrypt(
+ key: Uint8Array,
+ data: Uint8Array,
+): [decrypted: Uint8Array, nonce: Uint8Array] {
+ const [nonce, encrypted] = splitNonce(data);
+
+ return [
+ xchacha20poly1305(key, nonce).decrypt(encrypted),
+ nonce,
+ ];
+}
+
+/**
+ * Splits a nonce and a buffer into two separate arrays.
+ *
+ * @param buffer Buffer to split
+ */
+export function splitNonce(buffer: Uint8Array): [nonce: Uint8Array, buffer: Uint8Array] {
+ if (buffer.length < NONCE_LENGTH) {
+ throw new Error("Invalid buffer length");
+ }
+
+ return [
+ buffer.subarray(0, NONCE_LENGTH),
+ buffer.subarray(NONCE_LENGTH),
+ ];
+}
+
+/**
+ * Returns the Poly1305 authentication tag from an encrypted chunk.
+ *
+ * @param encryptedChunk
+ */
+export function getAuthTag(encryptedChunk: Uint8Array): Uint8Array {
+ return encryptedChunk.subarray(-16);
+}
diff --git a/src/lib/tulip/utils/buffer.ts b/src/lib/tulip/utils/buffer.ts
new file mode 100644
index 0000000..ecd81ab
--- /dev/null
+++ b/src/lib/tulip/utils/buffer.ts
@@ -0,0 +1,28 @@
+type OptionalItem = T | null | undefined;
+
+/**
+ * Concatenates multiple Uint8Arrays into a single Uint8Array.
+ *
+ * @param arrays The Uint8Arrays to concatenate
+ */
+export function concat(...arrays: OptionalItem[]): Uint8Array {
+ if (arrays.length <= 1) {
+ return arrays[0] ?? new Uint8Array();
+ }
+
+ const length = arrays.reduce((carry, array) => carry + (array?.length ?? 0), 0);
+ const result = new Uint8Array(length);
+
+ let offset = 0;
+
+ for (const array of arrays) {
+ if (typeof array == "undefined" || array === null) {
+ continue;
+ }
+
+ result.set(array, offset);
+ offset += array.length;
+ }
+
+ return result;
+}
diff --git a/src/lib/types/json.ts b/src/lib/types/json.ts
new file mode 100644
index 0000000..bc1733f
--- /dev/null
+++ b/src/lib/types/json.ts
@@ -0,0 +1,5 @@
+export type JsonPrimitive = string | number | boolean | null;
+export type JsonArray = Json[];
+export type JsonObject = { [key: string]: Json };
+export type JsonComposite = JsonArray | JsonObject;
+export type Json = JsonPrimitive | JsonComposite;
diff --git a/src/lib/utils/base64url.ts b/src/lib/utils/base64url.ts
new file mode 100644
index 0000000..e504232
--- /dev/null
+++ b/src/lib/utils/base64url.ts
@@ -0,0 +1,88 @@
+// base64url characters
+const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=";
+const BASE64_LOOKUP = BASE64.split("");
+const BASE64_REVERSE_LOOKUP = BASE64_LOOKUP.reduce((acc, char, index) => {
+ acc[char.charCodeAt(0)] = index;
+
+ return acc;
+}, []);
+
+/**
+ * Encodes the given data as base64url.
+ *
+ * @param data The data to encode
+ */
+export function encodeBase64Url(data: Uint8Array): string {
+ const cutoff = data.length % 3;
+ let result = "";
+ let i = 0;
+
+ do { // pack three octets into four hexets
+ const o1 = data.at(i++) ?? 0;
+ const o2 = data.at(i++) ?? 0;
+ const o3 = data.at(i++) ?? 0;
+ const bits = o1 << 16 | o2 << 8 | o3;
+
+ const h1 = bits >> 18 & 0x3f;
+ const h2 = bits >> 12 & 0x3f;
+ const h3 = bits >> 6 & 0x3f;
+ const h4 = bits & 0x3f;
+
+ // Use hexets to index into b64, and append result to encoded string
+ result += BASE64_LOOKUP[h1]! + BASE64_LOOKUP[h2] + BASE64_LOOKUP[h3] + BASE64_LOOKUP[h4];
+ } while (i < data.length);
+
+ return cutoff > 0 ? result.slice(0, cutoff - 3) : result;
+}
+
+/**
+ * Decodes the given base64url encoded data.
+ *
+ * @param data The data to decode
+ */
+export function decodeBase64Url(data: string): Uint8Array {
+ let length = data.indexOf("=");
+ if (length === -1) {
+ length = data.length;
+ }
+
+ if (length === 0) {
+ return new Uint8Array();
+ }
+
+ const padding = "==".substring((2 - data.length * 3) & 3);
+ const result: number[] = [];
+ let i = 0;
+ let j = 0;
+
+ // Append possible padding
+ data += padding;
+
+ do {
+ // Unpack four hexets into three octets using index points in b64
+ const h1 = BASE64_REVERSE_LOOKUP[data.charCodeAt(i++)] ?? 0;
+ const h2 = BASE64_REVERSE_LOOKUP[data.charCodeAt(i++)] ?? 0;
+ const h3 = BASE64_REVERSE_LOOKUP[data.charCodeAt(i++)] ?? 0;
+ const h4 = BASE64_REVERSE_LOOKUP[data.charCodeAt(i++)] ?? 0;
+ const bits = h1 << 18 | h2 << 12 | h3 << 6 | h4;
+
+ const o1 = bits >> 16 & 0xff;
+ const o2 = bits >> 8 & 0xff;
+ const o3 = bits & 0xff;
+
+ if (h3 === 64) {
+ result[j++] = o1;
+ }
+ else if (h4 === 64) {
+ result[j++] = o1;
+ result[j++] = o2;
+ }
+ else {
+ result[j++] = o1;
+ result[j++] = o2;
+ result[j++] = o3;
+ }
+ } while (i < data.length);
+
+ return new Uint8Array(result);
+}
diff --git a/src/lib/utils/buffer.ts b/src/lib/utils/buffer.ts
new file mode 100644
index 0000000..570a19c
--- /dev/null
+++ b/src/lib/utils/buffer.ts
@@ -0,0 +1,16 @@
+export function expand(bytes: Uint8Array, length: number): Uint8Array {
+ if (length < bytes.length) {
+ throw new Error("The specified length cannot be smaller than the input array's length.");
+ }
+
+ if (length === bytes.length) {
+ return bytes;
+ }
+
+ const result = new Uint8Array(length);
+ const start = length - bytes.length;
+
+ result.set(bytes, start);
+
+ return result;
+}
diff --git a/src/lib/utils/components.ts b/src/lib/utils/components.ts
new file mode 100644
index 0000000..38bed11
--- /dev/null
+++ b/src/lib/utils/components.ts
@@ -0,0 +1,7 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/lib/utils/errors.ts b/src/lib/utils/errors.ts
new file mode 100644
index 0000000..e543c79
--- /dev/null
+++ b/src/lib/utils/errors.ts
@@ -0,0 +1,7 @@
+export function errorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return `${error}`;
+}
diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts
new file mode 100644
index 0000000..8549cd3
--- /dev/null
+++ b/src/lib/utils/format.ts
@@ -0,0 +1,44 @@
+/**
+ * Formats a file size in bytes to a human-readable format.
+ *
+ * @param sizeInBytes
+ */
+export function formatFileSize(sizeInBytes: number): string {
+ const units = ["B", "KB", "MB", "GB"];
+ let size = sizeInBytes;
+ let unitIndex = 0;
+
+ while (size >= 1000 && unitIndex < units.length - 1) {
+ size /= 1000;
+ unitIndex++;
+ }
+
+ return `${parseFloat(size.toFixed(2))} ${units[unitIndex]}`;
+}
+
+/**
+ * Parses a file size string into a number of bytes.
+ *
+ * @param size
+ */
+export function parseFileSize(size: string): number {
+ const normalized = size.trim().toLowerCase();
+ const match = normalized.match(/^(\d+(?:\.\d+)?)\s*(kb|mb|gb)$/);
+ if (match === null) {
+ throw new Error(`Invalid size format: ${size}`);
+ }
+
+ const [, valueStr, unit] = match;
+ if (typeof valueStr == "undefined" || typeof unit == "undefined") {
+ throw new Error(`Invalid size format: ${size}`);
+ }
+
+ const value = parseFloat(valueStr);
+ const unitMap: { [key: string]: number } = {
+ kb: 1000,
+ mb: 1000 * 1000,
+ gb: 1000 * 1000 * 1000,
+ };
+
+ return value * (unitMap[unit] ?? 1);
+}
diff --git a/src/lib/utils/id.ts b/src/lib/utils/id.ts
new file mode 100644
index 0000000..bd9cbab
--- /dev/null
+++ b/src/lib/utils/id.ts
@@ -0,0 +1,18 @@
+import { customAlphabet } from "nanoid";
+
+
+const ID_LENGTH = 7;
+
+const ALPHA_LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
+const ALPHA_UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+const DIGITS = "0123456789";
+
+const generator = customAlphabet(ALPHA_LOWERCASE + ALPHA_UPPERCASE + DIGITS, 7);
+
+export function randomId() {
+ return generator(ID_LENGTH);
+}
+
+export function randomString(length: number) {
+ return generator(length);
+}
diff --git a/src/lib/utils/plural.ts b/src/lib/utils/plural.ts
new file mode 100644
index 0000000..b544951
--- /dev/null
+++ b/src/lib/utils/plural.ts
@@ -0,0 +1,13 @@
+const PLURALS = {
+ "chunk": "chunks",
+} as const;
+
+/**
+ * Returns the plural form of the given key.
+ *
+ * @param word Thw word to pluralize
+ * @param count The count of the word
+ */
+export function pluralize(word: keyof typeof PLURALS, count: number): string {
+ return count === 1 ? `1 ${word}` : `${count} ${PLURALS[word]}`;
+}
diff --git a/src/routes/(auth)/+layout.svelte b/src/routes/(auth)/+layout.svelte
new file mode 100644
index 0000000..a04f33b
--- /dev/null
+++ b/src/routes/(auth)/+layout.svelte
@@ -0,0 +1,44 @@
+
+
+
+
+
+ {@render children()}
+
diff --git a/src/routes/(auth)/+page.svelte b/src/routes/(auth)/+page.svelte
new file mode 100644
index 0000000..bbb926b
--- /dev/null
+++ b/src/routes/(auth)/+page.svelte
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+ Hello {auth.state?.username}
+ Storage usage: {formatFileSize(cumulativeSize)}
+
+
+
+
+
+ 0 && "mt-8")}>
+ {#each sortedFiles as file}
+ {@const df = decrypted(file)}
+
+
+
+
+
+
+ {formatFileSize(fileSize(df))}
+
+
+ {df.type}
+
+
+ {pluralize("chunk", df.chunks.length)}
+
+
+
+
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+ handleView(df.id)}>View
+ handleDownload(df.id)}>Download
+
+ handleDelete(df)}
+ >
+ Delete
+
+
+
+
+
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/routes/(auth)/vaults/+page.svelte b/src/routes/(auth)/vaults/+page.svelte
new file mode 100644
index 0000000..d7cc0e7
--- /dev/null
+++ b/src/routes/(auth)/vaults/+page.svelte
@@ -0,0 +1,338 @@
+
+
+
+
+
+
+
+ Your Public Vaults
+
+ Here you can manage your public vaults into which other people can upload files.
+
+
+
+
+
+
+
+
+
+
+
+ {#each sortedVaults as vault}
+
+
+
+ {vault.name}
+
+
+ {dayjs(vault.createdAt).fromNow()}
+
+
+
+
+ (event.target as HTMLInputElement).select()}
+ readonly
+ />
+
+
+
+
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+ handleDeleteVault(vault)}
+ >
+ Delete vault
+
+
+
+
+
+
+
+
+
+ {#each vault.slots as slot}
+
+
+
+ {#if slot.file !== null}
+
+ {slot.name}
+
+ {:else}
+
+ {slot.name}
+
+ {/if}
+
+ {#if slot.file !== null}
+ {dayjs(slot.file.createdAt).fromNow()}
+ {:else}
+ no upload so far
+ {/if}
+
+
+
+
+
+ {#if slot.file !== null}
+ {formatFileSize(slot.file?.size ?? 0)}
+ /
+ {:else}
+ max.
+ {/if}
+ {formatFileSize(slot.maxSize)}
+
+ {#if slot.file !== null}
+
+ {slot.file.type}
+
+
+ {pluralize("chunk", slot.file.chunks.length)}
+
+ {/if}
+
+
+
+ {#if slot.file !== null}
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+ handleView(slot)}>View
+ handleDownload(slot)}>Download
+
+ handleClear(slot)}
+ >
+ Clear
+
+
+
+
+
+ {/if}
+
+ {/each}
+
+
+
+ {/each}
+
+
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts
new file mode 100644
index 0000000..41b983c
--- /dev/null
+++ b/src/routes/+layout.server.ts
@@ -0,0 +1,32 @@
+import { createContext } from "$lib/trpc/context";
+import { createCaller } from "$lib/trpc/router";
+import { redirect } from "@sveltejs/kit";
+import type { LayoutServerLoad } from "./$types";
+
+
+const GUEST_ROUTES = new Set([
+ "/register",
+ "/login",
+]);
+
+export const load: LayoutServerLoad = async (event) => {
+ const ctx = await createContext(event);
+ const result = await createCaller(ctx).auth();
+ const url = new URL(event.request.url);
+ const loggedIn = result !== null;
+
+ if (loggedIn) {
+ if (GUEST_ROUTES.has(url.pathname)) {
+ return redirect(307, "/");
+ }
+ }
+ else {
+ if (! GUEST_ROUTES.has(url.pathname) && ! url.pathname.startsWith("/v/")) {
+ return redirect(307, "/login");
+ }
+ }
+
+ return {
+ session: result,
+ };
+};
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
new file mode 100644
index 0000000..fff948e
--- /dev/null
+++ b/src/routes/+layout.svelte
@@ -0,0 +1,47 @@
+
+
+{@render children()}
+
diff --git a/src/routes/api/dl/[id]/+server.ts b/src/routes/api/dl/[id]/+server.ts
new file mode 100644
index 0000000..3beceba
--- /dev/null
+++ b/src/routes/api/dl/[id]/+server.ts
@@ -0,0 +1,30 @@
+import { db, schema } from "$lib/server/db";
+import { getSessionFromRequest, getSessionUser } from "$lib/server/session";
+import { error } from "@sveltejs/kit";
+import { and, eq } from "drizzle-orm";
+import type { RequestHandler } from "./$types";
+
+
+export const GET: RequestHandler = async (event) => {
+ const session = await getSessionFromRequest(event);
+ const user = await getSessionUser(session);
+ if (user === null) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+
+ const chunk = await db.query.chunks.findFirst({
+ where: and(
+ eq(schema.chunks.id, event.params.id),
+ eq(schema.chunks.userId, user.id),
+ ),
+ });
+ if (typeof chunk == "undefined") {
+ return error(404, "Chunk not found");
+ }
+
+ return new Response(chunk.blob as Uint8Array, {
+ headers: {
+ "Content-Type": "application/octet-stream",
+ },
+ });
+};
diff --git a/src/routes/api/ul/+server.ts b/src/routes/api/ul/+server.ts
new file mode 100644
index 0000000..f905dec
--- /dev/null
+++ b/src/routes/api/ul/+server.ts
@@ -0,0 +1,125 @@
+import { decrypt } from "$lib/server/crypto/encrypt";
+import { db, schema } from "$lib/server/db";
+import { getSessionFromRequest, getSessionUser } from "$lib/server/session";
+import type { SlotUploadTicket } from "$lib/server/types";
+import { bytesToUtf8 } from "@noble/ciphers/utils";
+import { error, json } from "@sveltejs/kit";
+import { eq, type InferSelectModel, sql } from "drizzle-orm";
+import type { RequestHandler } from "./$types";
+
+
+const NONCE_LENGTH = 24;
+const AUTH_TAG_LENGTH = 16;
+const OVERHEAD = NONCE_LENGTH + AUTH_TAG_LENGTH;
+
+export const POST: RequestHandler = async (event) => {
+ if (event.request.headers.get("x-application") !== "sveltekit") {
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ const data = await event.request.formData();
+ let userId: string;
+ let vaultSlot: InferSelectModel | undefined;
+
+ if (data.has("ticket")) {
+ const raw = data.get("ticket");
+ if (raw === null || typeof raw == "string") {
+ return error(422, "Invalid ticket");
+ }
+
+ const buffer = Buffer.from(await raw.arrayBuffer());
+ const decrypted = decrypt(buffer);
+ const ticket: SlotUploadTicket = JSON.parse(bytesToUtf8(decrypted));
+
+ if (ticket.timestamp < Date.now() / 1000 - 60 * 60) {
+ // Ticket expired
+ return error(422, "Ticket expired");
+ }
+
+ const slot = await db.query.slots.findFirst({
+ where: eq(schema.slots.id, ticket.slotId),
+ with: {
+ vault: true,
+ },
+ });
+ if (typeof slot == "undefined") {
+ return error(422, "Invalid ticket");
+ }
+
+ if (slot.fileId !== null) {
+ return error(422, "Slot not available");
+ }
+
+ userId = slot.vault.userId;
+ vaultSlot = slot;
+ }
+ else {
+ const session = await getSessionFromRequest(event);
+ const user = await getSessionUser(session);
+ if (user === null) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+
+ userId = user.id;
+ }
+
+ const blob = data.get("blob");
+ if (blob === null) {
+ return error(422, "Missing blob");
+ }
+
+ if (typeof blob == "string") {
+ return error(422, "Invalid blob");
+ }
+
+ const buffer = Buffer.from(await blob.arrayBuffer());
+
+ const [usage = { usage: 0 }] = await db
+ .select({ usage: sql`sum(length(${schema.chunks.blob}) - ${OVERHEAD})` })
+ .from(schema.chunks)
+ .where(eq(schema.chunks.userId, userId))
+ .groupBy(schema.chunks.userId);
+ const newUsage = usage.usage + (buffer.length - OVERHEAD);
+
+ if (newUsage > 1000 * 1000 * 1000) {
+ return error(422, "Quota exceeded");
+ }
+
+ if (typeof vaultSlot != "undefined" && vaultSlot.maxSize > 0) {
+ const [slotUsage = { usage: 0 }] = await db
+ .select({ usage: sql`sum(length(${schema.chunks.blob}) - ${OVERHEAD})` })
+ .from(schema.chunks)
+ .innerJoin(schema.slotChunks, eq(schema.slotChunks.chunkId, schema.chunks.id))
+ .where(eq(schema.slotChunks.slotId, vaultSlot.id))
+ .groupBy(schema.slotChunks.slotId);
+ const newUsage = slotUsage.usage + (buffer.length - OVERHEAD);
+
+ if (newUsage > vaultSlot.maxSize) {
+ return error(422, "Slot is full");
+ }
+ }
+
+ const chunk = await db.transaction(async (tx) => {
+ const [chunk] = await tx.insert(schema.chunks).values({
+ userId,
+ blob: buffer,
+ }).returning();
+ if (typeof chunk == "undefined") {
+ throw new Error("Failed to persist chunk");
+ }
+
+ // Create slot-chunk relation
+ if (typeof vaultSlot != "undefined") {
+ await tx.insert(schema.slotChunks).values({
+ slotId: vaultSlot.id,
+ chunkId: chunk.id,
+ });
+ }
+
+ return chunk;
+ });
+
+ return json({
+ id: chunk.id,
+ });
+};
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte
new file mode 100644
index 0000000..67e4f60
--- /dev/null
+++ b/src/routes/login/+page.svelte
@@ -0,0 +1,84 @@
+
+
+
diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte
new file mode 100644
index 0000000..57d480b
--- /dev/null
+++ b/src/routes/register/+page.svelte
@@ -0,0 +1,89 @@
+
+
+
diff --git a/src/routes/v/[token]/+page.svelte b/src/routes/v/[token]/+page.svelte
new file mode 100644
index 0000000..a28ae1d
--- /dev/null
+++ b/src/routes/v/[token]/+page.svelte
@@ -0,0 +1,143 @@
+
+
+{#if vault !== null}
+
+
+
+
+ {vault.name}
+
+
+ {dayjs(vault.createdAt).fromNow()}
+
+
+
+
+
+ {#each vault.slots as slot}
+ handleFile(slot, file)}/>
+ {/each}
+
+
+
+
+{/if}
diff --git a/src/routes/v/[token]/UploadEntry.svelte b/src/routes/v/[token]/UploadEntry.svelte
new file mode 100644
index 0000000..a556a12
--- /dev/null
+++ b/src/routes/v/[token]/UploadEntry.svelte
@@ -0,0 +1,87 @@
+
+
+
+
+
+ {props.vaultSlot.name}
+
+
+
+ {#if ! props.vaultSlot.available}
+
+ uploaded
+
+ {/if}
+
+ max. {formatFileSize(props.vaultSlot.maxSize)}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/service-worker/index.ts b/src/service-worker/index.ts
new file mode 100644
index 0000000..17bbae6
--- /dev/null
+++ b/src/service-worker/index.ts
@@ -0,0 +1,134 @@
+///
+///
+///
+///
+
+import { openDB } from "$lib/keyval";
+import { type RouterOutput, trpc as makeTrpcClient, type TrpcClient } from "$lib/trpc/client";
+import { x25519 } from "@noble/curves/ed25519";
+import { hkdf } from "@noble/hashes/hkdf";
+import { sha256 } from "@noble/hashes/sha2";
+import { download } from "~/features/storage/download";
+import { getDecryptedFileAttributes } from "~/features/storage/file";
+import { encodeFilename } from "~/service-worker/utils";
+import { decrypt } from "../lib/tulip";
+import { decodeBase64Url } from "../lib/utils/base64url";
+
+
+type RemoteFile = RouterOutput["files"]["files"][number];
+
+// https://kit.svelte.dev/docs/service-workers#type-safety
+const sw = self as unknown as ServiceWorkerGlobalScope;
+
+sw.addEventListener("install", (event) => {
+ event.waitUntil(sw.skipWaiting());
+});
+
+sw.addEventListener("activate", async (event) => {
+ event.waitUntil(sw.clients.claim());
+});
+
+sw.addEventListener("fetch", (event) => {
+ const url = new URL(event.request.url);
+
+ if (url.pathname.startsWith("/sw/")) {
+ event.respondWith(handleSWRequest(event));
+ }
+});
+
+async function handleSWRequest(event: FetchEvent) {
+ const url = new URL(event.request.url);
+ const trpc = makeTrpcClient({ fetch, url: { origin: url.origin } });
+ const key = await getKey();
+
+ if (key === null) {
+ return new Response("not authenticated", { status: 401 });
+ }
+
+ if (url.pathname.startsWith("/sw/v/")) {
+ return handleVaultFileRequest(trpc, url, key);
+ }
+
+ const [, id] = url.pathname.slice(1).split("/");
+ if (typeof id == "undefined") {
+ return new Response("invalid token", { status: 400 });
+ }
+
+ const file = await trpc.file.query({ id });
+ if (typeof file == "undefined") {
+ return new Response("not found", { status: 404 });
+ }
+
+ return respondWithFile(file, key, url.searchParams.get("dl") ? "download" : "view");
+}
+
+async function handleVaultFileRequest(trpc: TrpcClient, url: URL, key: Uint8Array) {
+ const [, , slotId] = url.pathname.slice(1).split("/");
+ if (typeof slotId == "undefined") {
+ return new Response("invalid token", { status: 400 });
+ }
+
+ const slot = await trpc.slot.query({ id: slotId });
+ if (typeof slot == "undefined") {
+ return new Response("slot not found", { status: 404 });
+ }
+
+ if (slot.fileId === null || slot.fileX25519PublicKey === null) {
+ return new Response("slot does not contain a file", { status: 400 });
+ }
+
+ const { x25519PrivateKey = null } = await trpc.auth.query() ?? {};
+ if (x25519PrivateKey === null) {
+ return new Response("missing x25519PrivateKey", { status: 400 });
+ }
+
+ const file = await trpc.file.query({ id: slot.fileId });
+ if (typeof file == "undefined") {
+ return new Response("file not found", { status: 404 });
+ }
+
+ const [decryptedX25519PrivateKey] = decrypt(key, decodeBase64Url(x25519PrivateKey));
+ const sharedSecret = x25519.getSharedSecret(decryptedX25519PrivateKey, decodeBase64Url(slot.fileX25519PublicKey));
+ const fileMasterKey = hkdf(sha256, sharedSecret, undefined, undefined, 32);
+
+ return respondWithFile(file, fileMasterKey, url.searchParams.get("dl") ? "download" : "view");
+}
+
+function respondWithFile(file: RemoteFile, key: Uint8Array, mode: "view" | "download") {
+ const attributes = getDecryptedFileAttributes(key, file);
+
+ const stream = new ReadableStream({
+ async start(controller) {
+ try {
+ for await (const chunk of download(key, file, attributes)) {
+ controller.enqueue(chunk);
+ }
+
+ controller.close();
+ }
+ catch (error) {
+ return controller.error(error);
+ }
+ },
+ cancel() {
+ console.log("Stream canceled");
+ },
+ });
+
+ const headers: HeadersInit = {
+ "Content-Type": attributes.type.length > 0 ? attributes.type : "application/octet-stream",
+ "Content-Length": attributes.size.toString(),
+ };
+
+ if (mode === "download") {
+ headers["Content-Disposition"] = `attachment; filename*=utf-8''${encodeFilename(attributes.name)}`;
+ }
+
+ return new Response(stream, { headers });
+}
+
+async function getKey(): Promise {
+ const keyval = await openDB();
+
+ return keyval?.get("masterKey");
+}
diff --git a/src/service-worker/utils.ts b/src/service-worker/utils.ts
new file mode 100644
index 0000000..ca70d14
--- /dev/null
+++ b/src/service-worker/utils.ts
@@ -0,0 +1,12 @@
+/**
+ * Encodes the given filename for RFC 5987 compliance.
+ *
+ * @param filename
+ */
+export function encodeFilename(filename: string) {
+ // RFC 5987 specifies using UTF-8 and percent encoding for extended filenames
+ // Use encodeURIComponent for non-ASCII characters and special characters
+ return encodeURIComponent(filename).replace(/['()]/g, (char) => (
+ `%${char.charCodeAt(0).toString(16).toUpperCase()}`
+ ));
+}
diff --git a/src/style/app.css b/src/style/app.css
new file mode 100644
index 0000000..b89d3d7
--- /dev/null
+++ b/src/style/app.css
@@ -0,0 +1,77 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 84% 4.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 84% 4.9%;
+ --primary: 221.2 83.2% 53.3%;
+ --primary-foreground: 210 40% 98%;
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 221.2 83.2% 53.3%;
+ --radius: 0.5rem;
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
+ }
+
+ /*@media (prefers-color-scheme: dark) {*/
+ /* :root {*/
+ /* --background: 222.2 84% 4.9%;*/
+ /* --foreground: 210 40% 98%;*/
+ /* --card: 222.2 84% 4.9%;*/
+ /* --card-foreground: 210 40% 98%;*/
+ /* --popover: 222.2 84% 4.9%;*/
+ /* --popover-foreground: 210 40% 98%;*/
+ /* --primary: 217.2 91.2% 59.8%;*/
+ /* --primary-foreground: 222.2 47.4% 11.2%;*/
+ /* --secondary: 217.2 32.6% 17.5%;*/
+ /* --secondary-foreground: 210 40% 98%;*/
+ /* --muted: 217.2 32.6% 17.5%;*/
+ /* --muted-foreground: 215 20.2% 65.1%;*/
+ /* --accent: 217.2 32.6% 17.5%;*/
+ /* --accent-foreground: 210 40% 98%;*/
+ /* --destructive: 0 62.8% 30.6%;*/
+ /* --destructive-foreground: 210 40% 98%;*/
+ /* --border: 217.2 32.6% 17.5%;*/
+ /* --input: 217.2 32.6% 17.5%;*/
+ /* --ring: 224.3 76.3% 48%;*/
+ /* --chart-1: 220 70% 50%;*/
+ /* --chart-2: 160 60% 45%;*/
+ /* --chart-3: 30 80% 55%;*/
+ /* --chart-4: 280 65% 60%;*/
+ /* --chart-5: 340 75% 55%;*/
+ /* }*/
+ /*}*/
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+
+ html {
+ @apply h-full bg-foreground/5;
+ }
+
+ body {
+ @apply min-h-full flex flex-col text-foreground;
+ }
+}
diff --git a/static/favicon.png b/static/favicon.png
new file mode 100644
index 0000000..825b9e6
Binary files /dev/null and b/static/favicon.png differ
diff --git a/svelte.config.js b/svelte.config.js
new file mode 100644
index 0000000..b120a7a
--- /dev/null
+++ b/svelte.config.js
@@ -0,0 +1,31 @@
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+import adapter from "svelte-adapter-bun";
+
+
+/** @type {import("@sveltejs/kit").Config} */
+const config = {
+ // Consult https://svelte.dev/docs/kit/integrations
+ // for more information about preprocessors
+ preprocess: vitePreprocess({ script: true }),
+
+ kit: {
+ // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
+ // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
+ // See https://svelte.dev/docs/kit/adapters for more information about adapters.
+ adapter: adapter(),
+
+ files: {
+ serviceWorker: './src/service-worker/index.ts',
+ },
+
+ csrf: {
+ checkOrigin: false,
+ },
+
+ alias: {
+ "~/*": "./src/*",
+ },
+ },
+};
+
+export default config;
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000..f9e3dcc
--- /dev/null
+++ b/tailwind.config.ts
@@ -0,0 +1,103 @@
+import type { Config } from "tailwindcss";
+import tailwindcssAnimate from "tailwindcss-animate";
+import { fontFamily } from "tailwindcss/defaultTheme";
+
+
+const config: Config = {
+ darkMode: "media",
+ content: ["./src/**/*.{html,js,svelte,ts}"],
+ safelist: ["dark"],
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border) / )",
+ input: "hsl(var(--input) / )",
+ ring: "hsl(var(--ring) / )",
+ background: "hsl(var(--background) / )",
+ foreground: "hsl(var(--foreground) / )",
+ primary: {
+ DEFAULT: "hsl(var(--primary) / )",
+ foreground: "hsl(var(--primary-foreground) / )",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary) / )",
+ foreground: "hsl(var(--secondary-foreground) / )",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive) / )",
+ foreground: "hsl(var(--destructive-foreground) / )",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted) / )",
+ foreground: "hsl(var(--muted-foreground) / )",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent) / )",
+ foreground: "hsl(var(--accent-foreground) / )",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover) / )",
+ foreground: "hsl(var(--popover-foreground) / )",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card) / )",
+ foreground: "hsl(var(--card-foreground) / )",
+ },
+ sidebar: {
+ DEFAULT: "hsl(var(--sidebar-background))",
+ foreground: "hsl(var(--sidebar-foreground))",
+ primary: "hsl(var(--sidebar-primary))",
+ "primary-foreground": "hsl(var(--sidebar-primary-foreground))",
+ accent: "hsl(var(--sidebar-accent))",
+ "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
+ border: "hsl(var(--sidebar-border))",
+ ring: "hsl(var(--sidebar-ring))",
+ },
+ },
+ borderRadius: {
+ xl: "calc(var(--radius) + 4px)",
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ fontFamily: {
+ sans: [...fontFamily.sans],
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ "caret-blink": {
+ "0%,70%,100%": { opacity: "1" },
+ "20%,50%": { opacity: "0" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ "caret-blink": "caret-blink 1.25s ease-out infinite",
+ },
+ transitionDuration: {
+ "25": "25ms",
+ },
+ scale: {
+ "97": ".97",
+ },
+ },
+ },
+ plugins: [tailwindcssAnimate],
+};
+
+export default config;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..d7a76f7
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "ts-node": {
+ "require": [
+ "tsconfig-paths/register"
+ ]
+ },
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "noUncheckedIndexedAccess": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler",
+ "baseUrl": "./",
+ "paths": {
+ "~/*": ["./src/*"],
+ "$lib": ["./src/lib"],
+ "$lib/*": ["./src/lib/*"]
+ }
+ }
+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
+ //
+ // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
+ // from the referenced tsconfig.json - TypeScript does not merge them in
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..89c1780
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,11 @@
+import { sveltekit } from "@sveltejs/kit/vite";
+import { defineConfig } from "vitest/config";
+
+
+export default defineConfig({
+ plugins: [sveltekit()],
+
+ test: {
+ include: ["src/**/*.{test,spec}.{js,ts}"],
+ },
+});