mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f128cccf6 | ||
|
|
a198e5f6dc |
No files matched your search
+3
-6
@@ -1,6 +1,5 @@
|
|||||||
.git
|
.git
|
||||||
node_modules
|
node_modules
|
||||||
/Makefile
|
|
||||||
|
|
||||||
# Output
|
# Output
|
||||||
.output
|
.output
|
||||||
@@ -11,15 +10,13 @@ node_modules
|
|||||||
/build
|
/build
|
||||||
/data
|
/data
|
||||||
/library
|
/library
|
||||||
/thumbnails
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Editors
|
# VSCode
|
||||||
/.vscode
|
/.vscode
|
||||||
/.idea
|
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
@@ -31,5 +28,5 @@ Thumbs.db
|
|||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
# E2E tests
|
# SQLite
|
||||||
/test/e2e/results
|
*.db
|
||||||
+1
-6
@@ -1,14 +1,9 @@
|
|||||||
# Required environment variables
|
# Required environment variables
|
||||||
DATABASE_PASSWORD=
|
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
|
|
||||||
# Optional environment variables
|
# Optional environment variables
|
||||||
DATABASE_HOST=
|
DATABASE_URL=
|
||||||
DATABASE_PORT=
|
|
||||||
DATABASE_USER=
|
|
||||||
DATABASE_NAME=
|
|
||||||
SESSION_EXPIRES=
|
SESSION_EXPIRES=
|
||||||
USER_CLIENT_CHALLENGE_EXPIRES=
|
USER_CLIENT_CHALLENGE_EXPIRES=
|
||||||
SESSION_UPGRADE_CHALLENGE_EXPIRES=
|
SESSION_UPGRADE_CHALLENGE_EXPIRES=
|
||||||
LIBRARY_PATH=
|
LIBRARY_PATH=
|
||||||
THUMBNAILS_PATH=
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
name: Docker Image Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract Docker metadata
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
id: meta
|
|
||||||
with:
|
|
||||||
images: ghcr.io/${{ github.repository }}
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=raw,value=latest
|
|
||||||
type=sha
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
+3
-5
@@ -9,15 +9,13 @@ node_modules
|
|||||||
/build
|
/build
|
||||||
/data
|
/data
|
||||||
/library
|
/library
|
||||||
/thumbnails
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Editors
|
# VSCode
|
||||||
/.vscode
|
/.vscode
|
||||||
/.idea
|
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
@@ -29,5 +27,5 @@ Thumbs.db
|
|||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
# E2E tests
|
# SQLite
|
||||||
/test/e2e/results
|
*.db
|
||||||
@@ -3,5 +3,8 @@ package-lock.json
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
|
# Output
|
||||||
|
/drizzle
|
||||||
|
|
||||||
# Documents
|
# Documents
|
||||||
*.md
|
*.md
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
"tailwindStylesheet": "./src/app.css",
|
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": "*.svelte",
|
"files": "*.svelte",
|
||||||
|
|||||||
+6
-5
@@ -2,17 +2,16 @@
|
|||||||
FROM node:22-alpine AS base
|
FROM node:22-alpine AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g pnpm@12
|
RUN npm install -g pnpm@9
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY pnpm-lock.yaml .
|
||||||
|
|
||||||
# Build Stage
|
# Build Stage
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
RUN pnpm fetch
|
RUN pnpm fetch
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm install --offline && \
|
RUN pnpm install --offline
|
||||||
pnpm build && \
|
RUN pnpm build
|
||||||
sed -i "s/http\.createServer()/http.createServer({ requestTimeout: 0 })/g" ./build/index.js
|
|
||||||
|
|
||||||
# Deploy Stage
|
# Deploy Stage
|
||||||
FROM base
|
FROM base
|
||||||
@@ -22,7 +21,9 @@ COPY package.json .
|
|||||||
RUN pnpm install --offline --prod
|
RUN pnpm install --offline --prod
|
||||||
|
|
||||||
COPY --from=build /app/build ./build
|
COPY --from=build /app/build ./build
|
||||||
|
COPY drizzle ./drizzle
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV BODY_SIZE_LIMIT=Infinity
|
ENV BODY_SIZE_LIMIT=Infinity
|
||||||
|
|
||||||
CMD ["node", "./build/index.js"]
|
CMD ["node", "./build/index.js"]
|
||||||
@@ -23,7 +23,7 @@ vim .env # 아래를 참고하여 환경 변수를 설정해 주세요.
|
|||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
모든 데이터는 `./data` 디렉터리에 아래에 저장될 거예요.
|
모든 데이터는 `./data` 디렉터리에 저장될 거예요.
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
@@ -31,8 +31,7 @@ docker compose up --build -d
|
|||||||
|
|
||||||
|이름|필수|기본값|설명|
|
|이름|필수|기본값|설명|
|
||||||
|:-|:-:|:-:|:-|
|
|:-|:-:|:-:|:-|
|
||||||
|`DATABASE_PASSWORD`|Y||데이터베이스에 접근하기 위해 필요한 비밀번호예요. 안전한 값으로 설정해 주세요.|
|
|`SESSION_SECRET`|Y||Session ID의 서명을 위해 사용돼요. 안전한 값으로 설정해 주세요.|
|
||||||
|`SESSION_SECRET`|Y||Session ID의 서명에 사용되는 비밀번호예요. 안전한 값으로 설정해 주세요.|
|
|
||||||
|`SESSION_EXPIRES`||`14d`|Session의 유효 시간이에요. Session은 마지막으로 사용된 후 설정된 유효 시간이 지나면 자동으로 삭제돼요.|
|
|`SESSION_EXPIRES`||`14d`|Session의 유효 시간이에요. Session은 마지막으로 사용된 후 설정된 유효 시간이 지나면 자동으로 삭제돼요.|
|
||||||
|`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.|
|
|`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.|
|
||||||
|`SESSION_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.|
|
|`SESSION_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
services:
|
|
||||||
database:
|
|
||||||
image: postgres:17
|
|
||||||
restart: always
|
|
||||||
volumes:
|
|
||||||
- database:/var/lib/postgresql/data
|
|
||||||
environment:
|
|
||||||
- POSTGRES_USER=${DATABASE_USER:-}
|
|
||||||
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?} # Required
|
|
||||||
- POSTGRES_DB=${DATABASE_NAME:-}
|
|
||||||
ports:
|
|
||||||
- ${DATABASE_PORT:-5432}:5432
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
database:
|
|
||||||
+2
-24
@@ -2,42 +2,20 @@ services:
|
|||||||
server:
|
server:
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
|
||||||
database:
|
|
||||||
condition: service_healthy
|
|
||||||
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
|
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/library:/app/data/library
|
- ./data:/app/data
|
||||||
- ./data/thumbnails:/app/data/thumbnails
|
|
||||||
environment:
|
environment:
|
||||||
# ArkVault
|
# ArkVault
|
||||||
- DATABASE_HOST=database
|
- DATABASE_URL=/app/data/database.sqlite
|
||||||
- DATABASE_USER=arkvault
|
|
||||||
- DATABASE_PASSWORD=${DATABASE_PASSWORD:?} # Required
|
|
||||||
- SESSION_SECRET=${SESSION_SECRET:?} # Required
|
- SESSION_SECRET=${SESSION_SECRET:?} # Required
|
||||||
- SESSION_EXPIRES
|
- SESSION_EXPIRES
|
||||||
- USER_CLIENT_CHALLENGE_EXPIRES
|
- USER_CLIENT_CHALLENGE_EXPIRES
|
||||||
- SESSION_UPGRADE_CHALLENGE_EXPIRES
|
- SESSION_UPGRADE_CHALLENGE_EXPIRES
|
||||||
- LIBRARY_PATH=/app/data/library
|
- LIBRARY_PATH=/app/data/library
|
||||||
- THUMBNAILS_PATH=/app/data/thumbnails
|
|
||||||
# SvelteKit
|
# SvelteKit
|
||||||
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
|
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
|
||||||
- XFF_DEPTH=${TRUST_PROXY:-}
|
- XFF_DEPTH=${TRUST_PROXY:-}
|
||||||
- NODE_ENV=${NODE_ENV:-production}
|
- NODE_ENV=${NODE_ENV:-production}
|
||||||
ports:
|
ports:
|
||||||
- ${PORT:-80}:3000
|
- ${PORT:-80}:3000
|
||||||
|
|
||||||
database:
|
|
||||||
image: postgres:17-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
|
|
||||||
volumes:
|
|
||||||
- ./data/database:/var/lib/postgresql/data
|
|
||||||
environment:
|
|
||||||
- POSTGRES_USER=arkvault
|
|
||||||
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?}
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/lib/server/db/schema",
|
||||||
|
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL || "local.db",
|
||||||
|
},
|
||||||
|
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
dialect: "sqlite",
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
CREATE TABLE `client` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`encryption_public_key` text NOT NULL,
|
||||||
|
`signature_public_key` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `user_client` (
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`client_id` integer NOT NULL,
|
||||||
|
`state` text DEFAULT 'challenging' NOT NULL,
|
||||||
|
PRIMARY KEY(`client_id`, `user_id`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `user_client_challenge` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`client_id` integer NOT NULL,
|
||||||
|
`answer` text NOT NULL,
|
||||||
|
`allowed_ip` text NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`user_id`,`client_id`) REFERENCES `user_client`(`user_id`,`client_id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `directory` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`parent_id` integer,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`master_encryption_key_version` integer NOT NULL,
|
||||||
|
`encrypted_data_encryption_key` text NOT NULL,
|
||||||
|
`data_encryption_key_version` integer NOT NULL,
|
||||||
|
`encrypted_name` text NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `directory_log` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`directory_id` integer NOT NULL,
|
||||||
|
`timestamp` integer NOT NULL,
|
||||||
|
`action` text NOT NULL,
|
||||||
|
`new_name` text,
|
||||||
|
FOREIGN KEY (`directory_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `file` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`parent_id` integer,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`path` text NOT NULL,
|
||||||
|
`master_encryption_key_version` integer NOT NULL,
|
||||||
|
`encrypted_data_encryption_key` text NOT NULL,
|
||||||
|
`data_encryption_key_version` integer NOT NULL,
|
||||||
|
`hmac_secret_key_version` integer,
|
||||||
|
`content_hmac` text,
|
||||||
|
`content_type` text NOT NULL,
|
||||||
|
`encrypted_content_iv` text NOT NULL,
|
||||||
|
`encrypted_name` text NOT NULL,
|
||||||
|
FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`user_id`,`hmac_secret_key_version`) REFERENCES `hmac_secret_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `file_log` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`file_id` integer NOT NULL,
|
||||||
|
`timestamp` integer NOT NULL,
|
||||||
|
`action` text NOT NULL,
|
||||||
|
`new_name` text,
|
||||||
|
FOREIGN KEY (`file_id`) REFERENCES `file`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `hmac_secret_key` (
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`version` integer NOT NULL,
|
||||||
|
`state` text NOT NULL,
|
||||||
|
`master_encryption_key_version` integer NOT NULL,
|
||||||
|
`encrypted_key` text NOT NULL,
|
||||||
|
PRIMARY KEY(`user_id`, `version`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `hmac_secret_key_log` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`hmac_secret_key_version` integer NOT NULL,
|
||||||
|
`timestamp` integer NOT NULL,
|
||||||
|
`action` text NOT NULL,
|
||||||
|
`action_by` integer,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`action_by`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`user_id`,`hmac_secret_key_version`) REFERENCES `hmac_secret_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `client_master_encryption_key` (
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`client_id` integer NOT NULL,
|
||||||
|
`version` integer NOT NULL,
|
||||||
|
`encrypted_key` text NOT NULL,
|
||||||
|
`encrypted_key_signature` text NOT NULL,
|
||||||
|
PRIMARY KEY(`client_id`, `user_id`, `version`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`user_id`,`version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `master_encryption_key` (
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`version` integer NOT NULL,
|
||||||
|
`state` text NOT NULL,
|
||||||
|
`retired_at` integer,
|
||||||
|
PRIMARY KEY(`user_id`, `version`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `master_encryption_key_log` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`master_encryption_key_version` integer NOT NULL,
|
||||||
|
`timestamp` integer NOT NULL,
|
||||||
|
`action` text NOT NULL,
|
||||||
|
`action_by` integer,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`action_by`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `session` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`client_id` integer,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`last_used_at` integer NOT NULL,
|
||||||
|
`last_used_by_ip` text,
|
||||||
|
`last_used_by_user_agent` text,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `session_upgrade_challenge` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`session_id` text NOT NULL,
|
||||||
|
`client_id` integer NOT NULL,
|
||||||
|
`answer` text NOT NULL,
|
||||||
|
`allowed_ip` text NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `user` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`email` text NOT NULL,
|
||||||
|
`password` text NOT NULL,
|
||||||
|
`nickname` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `client_encryption_public_key_unique` ON `client` (`encryption_public_key`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `client_signature_public_key_unique` ON `client` (`signature_public_key`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `client_encryption_public_key_signature_public_key_unique` ON `client` (`encryption_public_key`,`signature_public_key`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_client_challenge_answer_unique` ON `user_client_challenge` (`answer`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `directory_encrypted_data_encryption_key_unique` ON `directory` (`encrypted_data_encryption_key`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `file_path_unique` ON `file` (`path`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `file_encrypted_data_encryption_key_unique` ON `file` (`encrypted_data_encryption_key`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `hmac_secret_key_encrypted_key_unique` ON `hmac_secret_key` (`encrypted_key`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `session_user_id_client_id_unique` ON `session` (`user_id`,`client_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `session_upgrade_challenge_session_id_unique` ON `session_upgrade_challenge` (`session_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `session_upgrade_challenge_answer_unique` ON `session_upgrade_challenge` (`answer`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1736704436996,
|
||||||
|
"tag": "0000_unknown_stark_industries",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+9
-50
@@ -1,40 +1,21 @@
|
|||||||
import { includeIgnoreFile } from "@eslint/compat";
|
|
||||||
import js from "@eslint/js";
|
|
||||||
import { loadConfig } from "@sveltejs/load-config";
|
|
||||||
import { defineConfig } from "eslint/config";
|
|
||||||
import prettier from "eslint-config-prettier";
|
import prettier from "eslint-config-prettier";
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import { includeIgnoreFile } from "@eslint/compat";
|
||||||
import svelte from "eslint-plugin-svelte";
|
import svelte from "eslint-plugin-svelte";
|
||||||
import tailwind from "eslint-plugin-tailwindcss";
|
import tailwind from "eslint-plugin-tailwindcss";
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import ts from "typescript-eslint";
|
import ts from "typescript-eslint";
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
|
|
||||||
const svelteConfig = (await loadConfig("./", { traverse: false }))?.config;
|
|
||||||
|
|
||||||
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
|
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
|
||||||
|
|
||||||
// Restricts the direction of dependencies between modules
|
export default ts.config(
|
||||||
const defineImportBoundaries = (boundaries) =>
|
|
||||||
Object.entries(boundaries).map(([files, regex]) => ({
|
|
||||||
files: [files],
|
|
||||||
rules: {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
patterns: [{ regex, message: "Violates the dependency direction between modules." }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default defineConfig(
|
|
||||||
includeIgnoreFile(gitignorePath),
|
includeIgnoreFile(gitignorePath),
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...ts.configs.recommended,
|
...ts.configs.recommended,
|
||||||
...svelte.configs.recommended,
|
...svelte.configs["flat/recommended"],
|
||||||
tailwind.configs.recommended,
|
...tailwind.configs["flat/recommended"],
|
||||||
prettier,
|
prettier,
|
||||||
...svelte.configs.prettier,
|
...svelte.configs["flat/prettier"],
|
||||||
{
|
{
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
@@ -42,35 +23,13 @@ export default defineConfig(
|
|||||||
...globals.node,
|
...globals.node,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
|
||||||
tailwindcss: {
|
|
||||||
cssConfigPath: "./src/app.css",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"no-undef": "off",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
...defineImportBoundaries({
|
|
||||||
// Pure code shared by the server, the client, and the service worker
|
|
||||||
"src/lib/shared/**": "^(#lib/(?!shared/)|#trpc/|#workers/|\\$app/|svelte($|/))",
|
|
||||||
"src/lib/server/**": "^(#lib/(client|features|ui)/|#trpc/)",
|
|
||||||
// Infrastructure of the browser
|
|
||||||
"src/lib/client/**": "^#lib/(server|features|ui)/",
|
|
||||||
// Domain-agnostic components
|
|
||||||
"src/lib/ui/**": "^(#lib/(server|client|features)/|#trpc/)",
|
|
||||||
"src/lib/features/**": "^#lib/server/",
|
|
||||||
"src/service-worker/**":
|
|
||||||
"^(#lib/(?!shared/|client/storage/|client/cache/file-cache\\.ts$)|#trpc/|#workers/)",
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
files: ["**/*.svelte"],
|
||||||
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: true,
|
|
||||||
extraFileExtensions: [".svelte"],
|
|
||||||
parser: ts.parser,
|
parser: ts.parser,
|
||||||
svelteConfig,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { defineConfig } from "kysely-ctl";
|
|
||||||
import { createDialect } from "./src/lib/server/db/dialect.ts";
|
|
||||||
|
|
||||||
// Empty values (e.g. `DATABASE_HOST=` in .env) are treated as unset, as in src/env.ts
|
|
||||||
const optional = (name: string) => process.env[name] || undefined;
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
dialect: createDialect({
|
|
||||||
host: optional("DATABASE_HOST"),
|
|
||||||
port: optional("DATABASE_PORT") ? Number(optional("DATABASE_PORT")) : undefined,
|
|
||||||
user: optional("DATABASE_USER"),
|
|
||||||
password: optional("DATABASE_PASSWORD"),
|
|
||||||
name: optional("DATABASE_NAME"),
|
|
||||||
}),
|
|
||||||
migrations: {
|
|
||||||
migrationFolder: "./src/lib/server/db/migrations",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
+40
-62
@@ -1,86 +1,64 @@
|
|||||||
{
|
{
|
||||||
"name": "arkvault",
|
"name": "arkvault",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.1",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"dev:remote": "__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=.ts.net vite dev --host 0.0.0.0",
|
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && tsc --noEmit --project ./src/service-worker",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"test": "vitest run",
|
"db:push": "drizzle-kit push",
|
||||||
"test:watch": "vitest",
|
"db:generate": "drizzle-kit generate",
|
||||||
"test:e2e": "bash test/e2e/run.sh",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:up": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d",
|
"db:studio": "drizzle-kit studio"
|
||||||
"db:down": "docker compose -f docker-compose.dev.yaml -p arkvault-dev down",
|
|
||||||
"db:migrate": "kysely migrate"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.1.1",
|
"@eslint/compat": "^1.2.4",
|
||||||
"@eslint/js": "^10.0.1",
|
"@iconify-json/material-symbols": "^1.2.12",
|
||||||
"@iconify-json/material-symbols": "^1.2.95",
|
"@sveltejs/adapter-node": "^5.2.11",
|
||||||
"@noble/hashes": "^2.4.0",
|
"@sveltejs/kit": "^2.15.2",
|
||||||
"@playwright/test": "1.64.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||||
"@sveltejs/adapter-node": "^6.0.0",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
"@sveltejs/kit": "^3.0.1",
|
|
||||||
"@sveltejs/load-config": "^0.2.2",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
|
||||||
"@tanstack/svelte-virtual": "^3.13.40",
|
|
||||||
"@testcontainers/postgresql": "^12.2.0",
|
|
||||||
"@trpc/client": "^11.19.0",
|
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/ms": "^2.1.0",
|
"@types/ms": "^0.7.34",
|
||||||
"@types/node-schedule": "^2.1.8",
|
"@types/node-schedule": "^2.1.7",
|
||||||
"@types/pg": "^8.23.1",
|
"autoprefixer": "^10.4.20",
|
||||||
"axios": "^1.20.0",
|
"dexie": "^4.0.10",
|
||||||
"dexie": "^4.4.6",
|
"drizzle-kit": "^0.22.8",
|
||||||
"es-hangul": "^2.4.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint": "^10.12.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
"eslint-plugin-svelte": "^3.23.1",
|
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||||
"eslint-plugin-tailwindcss": "^4.4.0",
|
|
||||||
"exifreader": "^4.48.0",
|
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"globals": "^17.13.0",
|
"globals": "^15.14.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"kysely-ctl": "^0.21.0",
|
"mime": "^4.0.6",
|
||||||
"lru-cache": "^11.5.3",
|
"prettier": "^3.4.2",
|
||||||
"mime": "^4.1.0",
|
"prettier-plugin-svelte": "^3.3.2",
|
||||||
"p-limit": "^7.3.3",
|
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||||
"prettier": "^3.9.10",
|
"svelte": "^5.17.1",
|
||||||
"prettier-plugin-svelte": "^4.1.1",
|
"svelte-check": "^4.1.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
"tailwindcss": "^3.4.17",
|
||||||
"svelte": "^5.57.2",
|
"typescript": "^5.7.3",
|
||||||
"svelte-check": "^4.7.6",
|
"typescript-eslint": "^8.19.1",
|
||||||
"tailwindcss": "^4.3.3",
|
"unplugin-icons": "^0.22.0",
|
||||||
"typescript": "^6.0.3",
|
"vite": "^5.4.11"
|
||||||
"typescript-eslint": "^8.71.1",
|
|
||||||
"unplugin-icons": "^24.0.0",
|
|
||||||
"vite": "^8.3.4",
|
|
||||||
"vitest": "^5.0.3"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@trpc/server": "^11.19.0",
|
"argon2": "^0.41.1",
|
||||||
"argon2": "^0.45.1",
|
"better-sqlite3": "^11.7.2",
|
||||||
"kysely": "^0.29.6",
|
"drizzle-orm": "^0.33.0",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"node-schedule": "^2.1.1",
|
"node-schedule": "^2.1.1",
|
||||||
"pg": "^8.23.1",
|
"uuid": "^11.0.4",
|
||||||
"superjson": "^2.2.6",
|
"zod": "^3.24.1"
|
||||||
"zod": "^4.6.5"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.17.0",
|
"node": "^22.0.0",
|
||||||
"pnpm": "^12.0.0"
|
"pnpm": "^9.0.0"
|
||||||
},
|
|
||||||
"imports": {
|
|
||||||
"#lib/*": "./src/lib/*",
|
|
||||||
"#trpc/*": "./src/trpc/*",
|
|
||||||
"#workers/*": "./src/workers/*"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+2457
-3274
File diff suppressed because it is too large.
Load diff
@@ -1,6 +0,0 @@
|
|||||||
allowBuilds:
|
|
||||||
argon2: true
|
|
||||||
cpu-features: false
|
|
||||||
exifreader: false
|
|
||||||
protobufjs: false
|
|
||||||
ssh2: false
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
+3
-48
@@ -1,48 +1,3 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss/base";
|
||||||
|
@import "tailwindcss/components";
|
||||||
@theme {
|
@import "tailwindcss/utilities";
|
||||||
--color-primary-100: #ffe3e3;
|
|
||||||
--color-primary-200: #ffc9c9;
|
|
||||||
--color-primary-300: #ffa8a8;
|
|
||||||
--color-primary-400: #ff8787;
|
|
||||||
--color-primary-500: #ff6b6b;
|
|
||||||
--color-primary-600: #fa5252;
|
|
||||||
--color-primary-700: #f03e3e;
|
|
||||||
--color-primary-800: #e03131;
|
|
||||||
--color-primary-900: #c92a2a;
|
|
||||||
--color-primary-000: #fff5f5;
|
|
||||||
|
|
||||||
--color-gray-100: #f1f3f5;
|
|
||||||
--color-gray-200: #e9ecef;
|
|
||||||
--color-gray-300: #dee2e6;
|
|
||||||
--color-gray-400: #ced4da;
|
|
||||||
--color-gray-500: #adb5bd;
|
|
||||||
--color-gray-600: #868e96;
|
|
||||||
--color-gray-700: #495057;
|
|
||||||
--color-gray-800: #343a40;
|
|
||||||
--color-gray-900: #212529;
|
|
||||||
--color-gray-000: #f8f9fa;
|
|
||||||
|
|
||||||
--font-sans:
|
|
||||||
"Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto,
|
|
||||||
"Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic",
|
|
||||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
|
||||||
so we've added these compatibility styles to make sure everything still
|
|
||||||
looks the same as it did with Tailwind CSS v3.
|
|
||||||
|
|
||||||
If we ever want to remove these styles, we need to add an explicit border
|
|
||||||
color utility to any element that depends on these defaults.
|
|
||||||
*/
|
|
||||||
@layer base {
|
|
||||||
*,
|
|
||||||
::after,
|
|
||||||
::before,
|
|
||||||
::backdrop,
|
|
||||||
::file-selector-button {
|
|
||||||
border-color: var(--color-gray-200, currentcolor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ko">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
|||||||
-45
@@ -1,45 +0,0 @@
|
|||||||
import { building } from "$app/env";
|
|
||||||
import { defineEnvVars } from "@sveltejs/kit/env";
|
|
||||||
import ms, { type StringValue } from "ms";
|
|
||||||
|
|
||||||
// Empty values (e.g. `LIBRARY_PATH=` in .env) are treated as unset
|
|
||||||
|
|
||||||
const required = (value: string | undefined) => {
|
|
||||||
if (!value && !building) throw new Error("Required environment variable not set");
|
|
||||||
return value ?? "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const optional = (value: string | undefined) => value || undefined;
|
|
||||||
|
|
||||||
const withDefault = (defaultValue: string) => (value: string | undefined) => value || defaultValue;
|
|
||||||
|
|
||||||
const duration = (defaultValue: StringValue) => (value: string | undefined) => {
|
|
||||||
const result = ms((value || defaultValue) as StringValue);
|
|
||||||
if (!Number.isFinite(result)) throw new Error(`Invalid duration: ${value}`);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const variables = defineEnvVars({
|
|
||||||
NODE_ENV: { schema: withDefault("development") },
|
|
||||||
|
|
||||||
DATABASE_HOST: { schema: optional },
|
|
||||||
DATABASE_PORT: {
|
|
||||||
schema: (value) => {
|
|
||||||
if (!value) return undefined;
|
|
||||||
const port = Number(value);
|
|
||||||
if (!Number.isInteger(port)) throw new Error(`Invalid port: ${value}`);
|
|
||||||
return port;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
DATABASE_USER: { schema: optional },
|
|
||||||
DATABASE_PASSWORD: { schema: required },
|
|
||||||
DATABASE_NAME: { schema: optional },
|
|
||||||
|
|
||||||
SESSION_SECRET: { schema: required },
|
|
||||||
SESSION_EXPIRES: { schema: duration("14d") },
|
|
||||||
USER_CLIENT_CHALLENGE_EXPIRES: { schema: duration("5m") },
|
|
||||||
SESSION_UPGRADE_CHALLENGE_EXPIRES: { schema: duration("5m") },
|
|
||||||
|
|
||||||
LIBRARY_PATH: { schema: withDefault("library") },
|
|
||||||
THUMBNAILS_PATH: { schema: withDefault("thumbnails") },
|
|
||||||
});
|
|
||||||
+27
-17
@@ -1,23 +1,33 @@
|
|||||||
import type { ClientInit } from "@sveltejs/kit/hooks";
|
import type { ClientInit } from "@sveltejs/kit";
|
||||||
import { cleanupFileCaches } from "#lib/client/cache/index.ts";
|
import { getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
|
||||||
import { keyring } from "#lib/client/keys/index.ts";
|
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
|
||||||
import { listenForServiceWorkerMessages } from "#lib/client/service-worker.ts";
|
|
||||||
import { cleanupDanglingInfos } from "#lib/client/storage/indexed-db/index.ts";
|
|
||||||
|
|
||||||
const requestPersistentStorage = async () => {
|
const prepareClientKeyStore = async () => {
|
||||||
const isPersistent = await navigator.storage.persist();
|
const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([
|
||||||
if (isPersistent) {
|
getClientKey("encrypt"),
|
||||||
console.log("[ArkVault] Persistent storage granted.");
|
getClientKey("decrypt"),
|
||||||
} else {
|
getClientKey("sign"),
|
||||||
console.warn("[ArkVault] Persistent storage not granted.");
|
getClientKey("verify"),
|
||||||
|
]);
|
||||||
|
if (encryptKey && decryptKey && signKey && verifyKey) {
|
||||||
|
clientKeyStore.set({ encryptKey, decryptKey, signKey, verifyKey });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareMasterKeyStore = async () => {
|
||||||
|
const masterKeys = await getMasterKeys();
|
||||||
|
if (masterKeys.length > 0) {
|
||||||
|
masterKeyStore.set(new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey])));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareHmacSecretStore = async () => {
|
||||||
|
const hmacSecrets = await getHmacSecrets();
|
||||||
|
if (hmacSecrets.length > 0) {
|
||||||
|
hmacSecretStore.set(new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret])));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const init: ClientInit = async () => {
|
export const init: ClientInit = async () => {
|
||||||
listenForServiceWorkerMessages();
|
await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore(), prepareHmacSecretStore()]);
|
||||||
|
|
||||||
await Promise.all([requestPersistentStorage(), keyring.load()]);
|
|
||||||
|
|
||||||
cleanupDanglingInfos(); // Intended
|
|
||||||
cleanupFileCaches().catch(console.error);
|
|
||||||
};
|
};
|
||||||
+14
-22
@@ -1,30 +1,22 @@
|
|||||||
import { sequence, type ServerInit } from "@sveltejs/kit/hooks";
|
import type { ServerInit } from "@sveltejs/kit";
|
||||||
|
import { sequence } from "@sveltejs/kit/hooks";
|
||||||
import schedule from "node-schedule";
|
import schedule from "node-schedule";
|
||||||
import { migrateDB } from "#lib/server/db/kysely.ts";
|
import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
|
||||||
import { authenticate, setAgentInfo } from "#lib/server/middlewares/index.ts";
|
import { migrateDB } from "$lib/server/db/drizzle";
|
||||||
import {
|
import {
|
||||||
AuthService,
|
cleanupExpiredSessions,
|
||||||
ClientService,
|
cleanupExpiredSessionUpgradeChallenges,
|
||||||
FileDeletionService,
|
} from "$lib/server/db/session";
|
||||||
UploadService,
|
import { authenticate, setAgentInfo } from "$lib/server/middlewares";
|
||||||
} from "#lib/server/services/index.ts";
|
|
||||||
|
|
||||||
export const init: ServerInit = async () => {
|
export const init: ServerInit = () => {
|
||||||
await migrateDB();
|
migrateDB();
|
||||||
|
|
||||||
schedule.scheduleJob("0 * * * *", async () => {
|
schedule.scheduleJob("0 * * * *", () => {
|
||||||
// Failures are logged and retried at the next run, so they don't crash the server
|
cleanupExpiredUserClientChallenges();
|
||||||
const results = await Promise.allSettled([
|
cleanupExpiredSessions();
|
||||||
AuthService.cleanupExpiredSessions(),
|
cleanupExpiredSessionUpgradeChallenges();
|
||||||
ClientService.cleanupExpiredChallenges(),
|
|
||||||
UploadService.cleanupExpiredUploadSessions(),
|
|
||||||
]);
|
|
||||||
results.forEach((result) => {
|
|
||||||
if (result.status === "rejected") console.error(result.reason);
|
|
||||||
});
|
|
||||||
FileDeletionService.schedulePendingFileDeletions();
|
|
||||||
});
|
});
|
||||||
FileDeletionService.schedulePendingFileDeletions();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handle = sequence(setAgentInfo, authenticate);
|
export const handle = sequence(setAgentInfo, authenticate);
|
||||||
Vendored
-185
@@ -1,185 +0,0 @@
|
|||||||
import {
|
|
||||||
deleteFileCacheIndex,
|
|
||||||
getFileCacheIndex as getAllFileCacheIndexes,
|
|
||||||
getFileCacheIndexOf,
|
|
||||||
storeFileCacheIndex,
|
|
||||||
updateFileCacheRetrievedAt,
|
|
||||||
} from "#lib/client/storage/indexed-db/cache-index.ts";
|
|
||||||
import {
|
|
||||||
createWritable,
|
|
||||||
deleteDirectory,
|
|
||||||
deleteFile,
|
|
||||||
deleteFilesInDirectory,
|
|
||||||
getFile,
|
|
||||||
listDirectoryNames,
|
|
||||||
listFileNames,
|
|
||||||
moveFile,
|
|
||||||
} from "#lib/client/storage/opfs.ts";
|
|
||||||
import { CHUNK_SIZE, getChunkCount } from "#lib/shared/format/index.ts";
|
|
||||||
|
|
||||||
// Caches the plaintext of files in chunks, so that partially loaded files (e.g. videos which were
|
|
||||||
// partially played) can also be cached. Chunk `i` starts at `i * CHUNK_SIZE` of the plaintext,
|
|
||||||
// except that legacy files are cached as a single chunk. Used by both the pages and the service
|
|
||||||
// worker, so the index is always read from IndexedDB.
|
|
||||||
|
|
||||||
export interface FileCacheLayout {
|
|
||||||
totalSize: number;
|
|
||||||
chunkCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDirectoryPath = (fileId: number) => `/cache/file/${fileId}`;
|
|
||||||
const getChunkPath = (fileId: number, chunkIndex: number) =>
|
|
||||||
`${getDirectoryPath(fileId)}/${chunkIndex}`;
|
|
||||||
|
|
||||||
const getChunkSize = ({ totalSize, chunkCount }: FileCacheLayout, chunkIndex: number) => {
|
|
||||||
return chunkCount === 1 ? totalSize : Math.min(CHUNK_SIZE, totalSize - chunkIndex * CHUNK_SIZE);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFileCacheIndex = async () => {
|
|
||||||
return await getAllFileCacheIndexes();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCachedChunkIndexes = async (fileId: number) => {
|
|
||||||
const names = await listFileNames(getDirectoryPath(fileId));
|
|
||||||
return new Set(names.filter((name) => /^\d+$/.test(name)).map(Number));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const readCachedChunk = async (fileId: number, chunkIndex: number) => {
|
|
||||||
return await getFile(getChunkPath(fileId, chunkIndex));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const touchFileCache = async (fileId: number) => {
|
|
||||||
await updateFileCacheRetrievedAt(fileId, new Date());
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateIndex = async (fileId: number, layout: FileCacheLayout) => {
|
|
||||||
const chunkIndexes = await getCachedChunkIndexes(fileId);
|
|
||||||
const size = chunkIndexes
|
|
||||||
.values()
|
|
||||||
.filter((chunkIndex) => chunkIndex < layout.chunkCount)
|
|
||||||
.reduce((size, chunkIndex) => size + getChunkSize(layout, chunkIndex), 0);
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
await storeFileCacheIndex({
|
|
||||||
fileId,
|
|
||||||
cachedAt: (await getFileCacheIndexOf(fileId))?.cachedAt ?? now,
|
|
||||||
lastRetrievedAt: now,
|
|
||||||
size,
|
|
||||||
totalSize: layout.totalSize,
|
|
||||||
chunkCount: layout.chunkCount,
|
|
||||||
isComplete: size === layout.totalSize,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Writes of the same file are serialized so that the index isn't updated with a stale state
|
|
||||||
const writeQueues = new Map<number, Promise<unknown>>();
|
|
||||||
|
|
||||||
const enqueueWrite = <T>(fileId: number, write: () => Promise<T>) => {
|
|
||||||
const result = (writeQueues.get(fileId) ?? Promise.resolve()).catch(() => {}).then(write);
|
|
||||||
const settled = result.catch(() => {});
|
|
||||||
writeQueues.set(fileId, settled);
|
|
||||||
void settled.then(() => {
|
|
||||||
if (writeQueues.get(fileId) === settled) {
|
|
||||||
writeQueues.delete(fileId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const storeCachedChunk = (
|
|
||||||
fileId: number,
|
|
||||||
chunkIndex: number,
|
|
||||||
data: Uint8Array | Blob,
|
|
||||||
layout: FileCacheLayout,
|
|
||||||
) => enqueueWrite(fileId, () => writeChunk(fileId, chunkIndex, data, layout));
|
|
||||||
|
|
||||||
const writeChunk = async (
|
|
||||||
fileId: number,
|
|
||||||
chunkIndex: number,
|
|
||||||
data: Uint8Array | Blob,
|
|
||||||
layout: FileCacheLayout,
|
|
||||||
) => {
|
|
||||||
const path = getChunkPath(fileId, chunkIndex);
|
|
||||||
const tempPath = `${path}.${crypto.randomUUID()}.tmp`;
|
|
||||||
|
|
||||||
// Written to a temporary file first, so that incomplete chunks are never visible
|
|
||||||
const writable = await createWritable(tempPath);
|
|
||||||
try {
|
|
||||||
await writable.write(data as FileSystemWriteChunkType);
|
|
||||||
await writable.close();
|
|
||||||
await moveFile(tempPath, path);
|
|
||||||
} catch (e) {
|
|
||||||
await writable.abort().catch(() => {});
|
|
||||||
await deleteFile(tempPath).catch(() => {});
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateIndex(fileId, layout);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UPLOAD_CACHE_DIRECTORY = "/cache/upload";
|
|
||||||
const STALE_UPLOAD_CACHE_AGE = 24 * 3600 * 1000; // 24 hours
|
|
||||||
|
|
||||||
// Caches the plaintext of a file while it is being uploaded. Chunks are written to a temporary
|
|
||||||
// directory, and moved to the cache once the id of the file is known.
|
|
||||||
export const createUploadFileCache = (totalSize: number) => {
|
|
||||||
const layout = { totalSize, chunkCount: getChunkCount(totalSize) };
|
|
||||||
// Prefixed with the creation time to find stale ones (e.g. the tab was closed while uploading)
|
|
||||||
const directoryPath = `${UPLOAD_CACHE_DIRECTORY}/${Date.now()}-${crypto.randomUUID()}`;
|
|
||||||
const writtenChunkIndexes = new Set<number>();
|
|
||||||
let isFailed = false;
|
|
||||||
|
|
||||||
const discard = async () => {
|
|
||||||
isFailed = true;
|
|
||||||
await deleteDirectory(directoryPath).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Failing to cache shouldn't fail the upload, so errors are ignored
|
|
||||||
async writeChunk(chunkIndex: number, data: ArrayBuffer) {
|
|
||||||
if (isFailed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const writable = await createWritable(`${directoryPath}/${chunkIndex}`);
|
|
||||||
await writable.write(data);
|
|
||||||
await writable.close();
|
|
||||||
writtenChunkIndexes.add(chunkIndex);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
await discard();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async commit(fileId: number) {
|
|
||||||
if (isFailed || writtenChunkIndexes.size !== layout.chunkCount) {
|
|
||||||
await discard();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await enqueueWrite(fileId, async () => {
|
|
||||||
for (const chunkIndex of writtenChunkIndexes) {
|
|
||||||
await moveFile(`${directoryPath}/${chunkIndex}`, getChunkPath(fileId, chunkIndex));
|
|
||||||
}
|
|
||||||
await updateIndex(fileId, layout);
|
|
||||||
}).finally(discard);
|
|
||||||
},
|
|
||||||
discard,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteFileCache = async (fileId: number) => {
|
|
||||||
await deleteFileCacheIndex(fileId);
|
|
||||||
await deleteDirectory(getDirectoryPath(fileId));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const cleanupFileCaches = async () => {
|
|
||||||
// Files were cached as single files directly under `/cache` before
|
|
||||||
await deleteFilesInDirectory("/cache");
|
|
||||||
|
|
||||||
// Uploads might be in progress in other tabs, so only stale ones are deleted
|
|
||||||
for (const name of await listDirectoryNames(UPLOAD_CACHE_DIRECTORY)) {
|
|
||||||
const createdAt = parseInt(name, 10);
|
|
||||||
if (!(Date.now() - createdAt < STALE_UPLOAD_CACHE_AGE)) {
|
|
||||||
await deleteDirectory(`${UPLOAD_CACHE_DIRECTORY}/${name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Vendored
-2
@@ -1,2 +0,0 @@
|
|||||||
export * from "./file-cache.ts";
|
|
||||||
export * from "./thumbnail-cache.svelte.ts";
|
|
||||||
-86
@@ -1,86 +0,0 @@
|
|||||||
/* eslint-disable svelte/prefer-svelte-reactivity -- Lookup tables of reactive entries */
|
|
||||||
import { LRUCache } from "lru-cache";
|
|
||||||
import { deleteDirectory, deleteFile, readFile, writeFile } from "#lib/client/storage/opfs.ts";
|
|
||||||
import { decryptChunk, encodeToBase64 } from "#lib/shared/crypto/index.ts";
|
|
||||||
|
|
||||||
export interface Thumbnail {
|
|
||||||
url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => {
|
|
||||||
return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Thumbnails are reactive so that they can be updated in place
|
|
||||||
const loadedThumbnails = new LRUCache<number, Thumbnail>({ max: 100 });
|
|
||||||
const loadingThumbnails = new Map<number, Thumbnail>();
|
|
||||||
|
|
||||||
const fetchFromOpfs = async (fileId: number) => {
|
|
||||||
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
|
|
||||||
if (thumbnailBuffer) {
|
|
||||||
return getThumbnailUrl(thumbnailBuffer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
|
|
||||||
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
|
|
||||||
const thumbnailBuffer = await decryptChunk(await res.arrayBuffer(), dataKey);
|
|
||||||
|
|
||||||
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
|
|
||||||
return getThumbnailUrl(thumbnailBuffer);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFileThumbnail = (file: {
|
|
||||||
id: number;
|
|
||||||
contentType: string;
|
|
||||||
dataKey?: { key: CryptoKey };
|
|
||||||
}) => {
|
|
||||||
if (!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const thumbnail = loadedThumbnails.get(file.id) ?? loadingThumbnails.get(file.id);
|
|
||||||
if (thumbnail) return thumbnail;
|
|
||||||
|
|
||||||
const loadingThumbnail: Thumbnail = $state({});
|
|
||||||
loadingThumbnails.set(file.id, loadingThumbnail);
|
|
||||||
|
|
||||||
fetchFromOpfs(file.id)
|
|
||||||
.then((url) => url ?? (file.dataKey && fetchFromServer(file.id, file.dataKey.key)))
|
|
||||||
.then((url) => {
|
|
||||||
if (url) {
|
|
||||||
loadingThumbnail.url = url;
|
|
||||||
loadedThumbnails.set(file.id, loadingThumbnail);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => {
|
|
||||||
loadingThumbnails.delete(file.id);
|
|
||||||
});
|
|
||||||
return loadingThumbnail;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
|
|
||||||
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
|
|
||||||
|
|
||||||
const url = getThumbnailUrl(thumbnailBuffer);
|
|
||||||
const thumbnail = loadedThumbnails.get(fileId) ?? loadingThumbnails.get(fileId);
|
|
||||||
if (thumbnail) {
|
|
||||||
thumbnail.url = url;
|
|
||||||
} else {
|
|
||||||
const newThumbnail: Thumbnail = $state({ url });
|
|
||||||
loadedThumbnails.set(fileId, newThumbnail);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteFileThumbnailCache = async (fileId: number) => {
|
|
||||||
loadedThumbnails.delete(fileId);
|
|
||||||
await deleteFile(`/thumbnail/file/${fileId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteAllFileThumbnailCaches = async () => {
|
|
||||||
loadedThumbnails.clear();
|
|
||||||
await deleteDirectory(`/thumbnail/file`);
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { ComputeMessage, ResultMessage } from "#workers/hmac.ts";
|
|
||||||
import HmacWorker from "#workers/hmac.ts?worker";
|
|
||||||
|
|
||||||
// Computes HMAC of large blobs incrementally in a worker, which WebCrypto doesn't support
|
|
||||||
export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => {
|
|
||||||
const stream = message.stream();
|
|
||||||
const hmacSecretRaw = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret));
|
|
||||||
const worker = new HmacWorker();
|
|
||||||
|
|
||||||
return new Promise<Uint8Array>((resolve, reject) => {
|
|
||||||
worker.onmessage = ({ data }: MessageEvent<ResultMessage>) => {
|
|
||||||
if ("result" in data) {
|
|
||||||
resolve(data.result);
|
|
||||||
} else {
|
|
||||||
reject(data.error);
|
|
||||||
}
|
|
||||||
worker.terminate();
|
|
||||||
};
|
|
||||||
|
|
||||||
worker.onerror = ({ error }) => {
|
|
||||||
reject(error);
|
|
||||||
worker.terminate();
|
|
||||||
};
|
|
||||||
|
|
||||||
worker.postMessage({ stream, key: hmacSecretRaw } satisfies ComputeMessage, {
|
|
||||||
transfer: [stream, hmacSecretRaw.buffer],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import {
|
|
||||||
concatenateBuffers,
|
|
||||||
digestMessage,
|
|
||||||
exportRSAKey,
|
|
||||||
exportRSAKeyToBase64,
|
|
||||||
generateEncryptionKeyPair,
|
|
||||||
generateHmacSecret,
|
|
||||||
generateMasterKey,
|
|
||||||
generateSigningKeyPair,
|
|
||||||
importEncryptionKeyPairFromBase64,
|
|
||||||
importSigningKeyPairFromBase64,
|
|
||||||
makeAESKeyNonextractable,
|
|
||||||
makeRSAKeyNonextractable,
|
|
||||||
wrapHmacSecret,
|
|
||||||
wrapMasterKey,
|
|
||||||
} from "#lib/shared/crypto/index.ts";
|
|
||||||
import { keyring } from "./keyring.svelte.ts";
|
|
||||||
import { deserializeClientKeys } from "./serialization.ts";
|
|
||||||
|
|
||||||
// Generates new client keys and keeps them in the keyring without persisting
|
|
||||||
export const generateClientKeys = async () => {
|
|
||||||
const { encryptKey, decryptKey } = await generateEncryptionKeyPair();
|
|
||||||
const { signKey, verifyKey } = await generateSigningKeyPair();
|
|
||||||
|
|
||||||
keyring.setClientKeys({
|
|
||||||
encryptKey,
|
|
||||||
decryptKey: await makeRSAKeyNonextractable(decryptKey),
|
|
||||||
signKey: await makeRSAKeyNonextractable(signKey),
|
|
||||||
verifyKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
encryptKey,
|
|
||||||
encryptKeyBase64: await exportRSAKeyToBase64(encryptKey),
|
|
||||||
decryptKeyBase64: await exportRSAKeyToBase64(decryptKey),
|
|
||||||
signKeyBase64: await exportRSAKeyToBase64(signKey),
|
|
||||||
verifyKeyBase64: await exportRSAKeyToBase64(verifyKey),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const importClientKeys = async (clientKeysSerialized: string) => {
|
|
||||||
const clientKeys = deserializeClientKeys(clientKeysSerialized);
|
|
||||||
if (!clientKeys) return false;
|
|
||||||
|
|
||||||
const { encryptKey, decryptKey } = await importEncryptionKeyPairFromBase64(
|
|
||||||
clientKeys.encryptKeyBase64,
|
|
||||||
clientKeys.decryptKeyBase64,
|
|
||||||
);
|
|
||||||
const { signKey, verifyKey } = await importSigningKeyPairFromBase64(
|
|
||||||
clientKeys.signKeyBase64,
|
|
||||||
clientKeys.verifyKeyBase64,
|
|
||||||
);
|
|
||||||
|
|
||||||
keyring.setClientKeys({
|
|
||||||
encryptKey,
|
|
||||||
decryptKey: await makeRSAKeyNonextractable(decryptKey),
|
|
||||||
signKey: await makeRSAKeyNonextractable(signKey),
|
|
||||||
verifyKey,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateInitialMasterKey = async (encryptKey: CryptoKey) => {
|
|
||||||
const { masterKey } = await generateMasterKey();
|
|
||||||
return {
|
|
||||||
masterKey: await makeAESKeyNonextractable(masterKey),
|
|
||||||
masterKeyWrapped: await wrapMasterKey(masterKey, encryptKey),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateInitialHmacSecret = async (masterKey: CryptoKey) => {
|
|
||||||
const { hmacSecret } = await generateHmacSecret();
|
|
||||||
return {
|
|
||||||
hmacSecretWrapped: await wrapHmacSecret(hmacSecret, masterKey),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generatePublicKeyFingerprint = async (encryptKey: CryptoKey, verifyKey: CryptoKey) => {
|
|
||||||
const { key: encryptKeyBuffer } = await exportRSAKey(encryptKey);
|
|
||||||
const { key: verifyKeyBuffer } = await exportRSAKey(verifyKey);
|
|
||||||
const digest = await digestMessage(concatenateBuffers(encryptKeyBuffer, verifyKeyBuffer));
|
|
||||||
return Array.from(new Uint8Array(digest))
|
|
||||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
||||||
.join("")
|
|
||||||
.toUpperCase()
|
|
||||||
.match(/.{1,4}/g)!
|
|
||||||
.join(" ");
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export * from "./generation.ts";
|
|
||||||
export * from "./keyring.svelte.ts";
|
|
||||||
export * from "./serialization.ts";
|
|
||||||
export * from "./sync.ts";
|
|
||||||
export * from "./types.ts";
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import {
|
|
||||||
getClientKey,
|
|
||||||
getHmacSecrets,
|
|
||||||
getMasterKeys,
|
|
||||||
storeClientKey,
|
|
||||||
storeHmacSecrets,
|
|
||||||
storeMasterKeys,
|
|
||||||
} from "#lib/client/storage/indexed-db/index.ts";
|
|
||||||
import type { ClientKeys, HmacSecret, MasterKey } from "./types.ts";
|
|
||||||
|
|
||||||
const findLatestActive = <T extends { version: number; state: string }>(
|
|
||||||
entries: ReadonlyMap<number, T>,
|
|
||||||
) => {
|
|
||||||
return entries
|
|
||||||
.values()
|
|
||||||
.filter(({ state }) => state === "active")
|
|
||||||
.reduce<T | undefined>(
|
|
||||||
(latest, entry) => ((latest?.version ?? 0) > entry.version ? latest : entry),
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Holds the keys of this device and the user. Keys are persisted in IndexedDB.
|
|
||||||
class Keyring {
|
|
||||||
clientKeys = $state.raw<ClientKeys | null>(null);
|
|
||||||
masterKeys = $state.raw<ReadonlyMap<number, MasterKey>>(new Map());
|
|
||||||
hmacSecrets = $state.raw<ReadonlyMap<number, HmacSecret>>(new Map());
|
|
||||||
|
|
||||||
async load() {
|
|
||||||
const [encryptKey, decryptKey, signKey, verifyKey, masterKeys, hmacSecrets] = await Promise.all(
|
|
||||||
[
|
|
||||||
getClientKey("encrypt"),
|
|
||||||
getClientKey("decrypt"),
|
|
||||||
getClientKey("sign"),
|
|
||||||
getClientKey("verify"),
|
|
||||||
getMasterKeys(),
|
|
||||||
getHmacSecrets(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
if (encryptKey && decryptKey && signKey && verifyKey) {
|
|
||||||
this.clientKeys = { encryptKey, decryptKey, signKey, verifyKey };
|
|
||||||
}
|
|
||||||
this.masterKeys = new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey]));
|
|
||||||
this.hmacSecrets = new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client keys are kept in memory only until they are registered and persisted
|
|
||||||
setClientKeys(clientKeys: ClientKeys) {
|
|
||||||
this.clientKeys = clientKeys;
|
|
||||||
}
|
|
||||||
|
|
||||||
async persistClientKeys() {
|
|
||||||
const clientKeys = this.requireClientKeys();
|
|
||||||
await Promise.all([
|
|
||||||
storeClientKey(clientKeys.encryptKey, "encrypt"),
|
|
||||||
storeClientKey(clientKeys.decryptKey, "decrypt"),
|
|
||||||
storeClientKey(clientKeys.signKey, "sign"),
|
|
||||||
storeClientKey(clientKeys.verifyKey, "verify"),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setMasterKeys(masterKeys: MasterKey[]) {
|
|
||||||
await storeMasterKeys(masterKeys);
|
|
||||||
this.masterKeys = new Map(masterKeys.map((masterKey) => [masterKey.version, masterKey]));
|
|
||||||
}
|
|
||||||
|
|
||||||
async setHmacSecrets(hmacSecrets: HmacSecret[]) {
|
|
||||||
await storeHmacSecrets(hmacSecrets);
|
|
||||||
this.hmacSecrets = new Map(hmacSecrets.map((hmacSecret) => [hmacSecret.version, hmacSecret]));
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasMasterKeys() {
|
|
||||||
return this.masterKeys.size > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasHmacSecrets() {
|
|
||||||
return this.hmacSecrets.size > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
requireClientKeys() {
|
|
||||||
if (!this.clientKeys) {
|
|
||||||
throw new Error("Client keys not found");
|
|
||||||
}
|
|
||||||
return this.clientKeys;
|
|
||||||
}
|
|
||||||
|
|
||||||
getMasterKey(version: number) {
|
|
||||||
const masterKey = this.masterKeys.get(version);
|
|
||||||
if (!masterKey) {
|
|
||||||
throw new Error(`Master key (version ${version}) not found`);
|
|
||||||
}
|
|
||||||
return masterKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The master key used to encrypt new data
|
|
||||||
get activeMasterKey() {
|
|
||||||
const masterKey = findLatestActive(this.masterKeys);
|
|
||||||
if (!masterKey) {
|
|
||||||
throw new Error("Active master key not found");
|
|
||||||
}
|
|
||||||
return masterKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeHmacSecret() {
|
|
||||||
const hmacSecret = findLatestActive(this.hmacSecrets);
|
|
||||||
if (!hmacSecret) {
|
|
||||||
throw new Error("Active HMAC secret not found");
|
|
||||||
}
|
|
||||||
return hmacSecret;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const keyring = new Keyring();
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const SerializedClientKeysSchema = z.intersection(
|
|
||||||
z.object({
|
|
||||||
generator: z.literal("ArkVault"),
|
|
||||||
exportedAt: z.iso.datetime(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
version: z.literal(1),
|
|
||||||
encryptKey: z.base64().nonempty(),
|
|
||||||
decryptKey: z.base64().nonempty(),
|
|
||||||
signKey: z.base64().nonempty(),
|
|
||||||
verifyKey: z.base64().nonempty(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
type SerializedClientKeys = z.infer<typeof SerializedClientKeysSchema>;
|
|
||||||
|
|
||||||
type DeserializedClientKeys = {
|
|
||||||
encryptKeyBase64: string;
|
|
||||||
decryptKeyBase64: string;
|
|
||||||
signKeyBase64: string;
|
|
||||||
verifyKeyBase64: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const serializeClientKeys = ({
|
|
||||||
encryptKeyBase64,
|
|
||||||
decryptKeyBase64,
|
|
||||||
signKeyBase64,
|
|
||||||
verifyKeyBase64,
|
|
||||||
}: DeserializedClientKeys) => {
|
|
||||||
return JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
generator: "ArkVault",
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
encryptKey: encryptKeyBase64,
|
|
||||||
decryptKey: decryptKeyBase64,
|
|
||||||
signKey: signKeyBase64,
|
|
||||||
verifyKey: verifyKeyBase64,
|
|
||||||
} satisfies SerializedClientKeys);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deserializeClientKeys = (serialized: string) => {
|
|
||||||
const zodRes = SerializedClientKeysSchema.safeParse(JSON.parse(serialized));
|
|
||||||
if (zodRes.success) {
|
|
||||||
return {
|
|
||||||
encryptKeyBase64: zodRes.data.encryptKey,
|
|
||||||
decryptKeyBase64: zodRes.data.decryptKey,
|
|
||||||
signKeyBase64: zodRes.data.signKey,
|
|
||||||
verifyKeyBase64: zodRes.data.verifyKey,
|
|
||||||
} satisfies DeserializedClientKeys;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import {
|
|
||||||
decryptChallenge,
|
|
||||||
encodeToBase64,
|
|
||||||
exportRSAKeyToBase64,
|
|
||||||
signMasterKeyWrapped,
|
|
||||||
signMessageRSA,
|
|
||||||
unwrapHmacSecret,
|
|
||||||
unwrapMasterKey,
|
|
||||||
verifyMasterKeyWrapped,
|
|
||||||
} from "#lib/shared/crypto/index.ts";
|
|
||||||
import { isTRPCClientError, trpc } from "#trpc/client.ts";
|
|
||||||
import { keyring } from "./keyring.svelte.ts";
|
|
||||||
import type { ClientKeys } from "./types.ts";
|
|
||||||
|
|
||||||
// Synchronizes keys with the server
|
|
||||||
|
|
||||||
const exportPublicKeys = async ({ encryptKey, verifyKey }: ClientKeys) => ({
|
|
||||||
encPubKey: await exportRSAKeyToBase64(encryptKey),
|
|
||||||
sigPubKey: await exportRSAKeyToBase64(verifyKey),
|
|
||||||
});
|
|
||||||
|
|
||||||
const answerChallenge = async (challenge: string, { decryptKey, signKey }: ClientKeys) => {
|
|
||||||
const answer = await decryptChallenge(challenge, decryptKey);
|
|
||||||
return encodeToBase64(await signMessageRSA(answer, signKey));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requestSessionUpgrade = async (force = false) => {
|
|
||||||
const clientKeys = keyring.requireClientKeys();
|
|
||||||
|
|
||||||
let id, challenge;
|
|
||||||
try {
|
|
||||||
({ id, challenge } = await trpc.auth.upgrade.mutate(await exportPublicKeys(clientKeys)));
|
|
||||||
} catch (e) {
|
|
||||||
if (isTRPCClientError(e) && e.data?.code === "FORBIDDEN") {
|
|
||||||
return [false, "Unregistered client"] as const;
|
|
||||||
}
|
|
||||||
return [false] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await trpc.auth.verifyUpgrade.mutate({
|
|
||||||
id,
|
|
||||||
answerSig: await answerChallenge(challenge, clientKeys),
|
|
||||||
force,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (isTRPCClientError(e) && e.data?.code === "CONFLICT") {
|
|
||||||
return [false, "Already logged in"] as const;
|
|
||||||
}
|
|
||||||
return [false] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [true] as const;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requestClientRegistration = async () => {
|
|
||||||
const clientKeys = keyring.requireClientKeys();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { id, challenge } = await trpc.client.register.mutate(await exportPublicKeys(clientKeys));
|
|
||||||
await trpc.client.verify.mutate({
|
|
||||||
id,
|
|
||||||
answerSig: await answerChallenge(challenge, clientKeys),
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requestClientRegistrationAndSessionUpgrade = async (force: boolean) => {
|
|
||||||
const [res, error] = await requestSessionUpgrade(force);
|
|
||||||
if (error === undefined) return [res] as const;
|
|
||||||
|
|
||||||
if (error === "Unregistered client" && !(await requestClientRegistration())) {
|
|
||||||
return [false] as const;
|
|
||||||
} else if (error === "Already logged in") {
|
|
||||||
return [false, force ? undefined : error] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [(await requestSessionUpgrade())[0]] as const;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const registerInitialMasterKeyAndHmacSecret = async (
|
|
||||||
masterKeyWrapped: string,
|
|
||||||
hmacSecretWrapped: string,
|
|
||||||
) => {
|
|
||||||
const { signKey } = keyring.requireClientKeys();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await trpc.mek.registerInitial.mutate({
|
|
||||||
mek: masterKeyWrapped,
|
|
||||||
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (isTRPCClientError(e) && (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")) {
|
|
||||||
return true; // Already registered by another client
|
|
||||||
}
|
|
||||||
console.error(e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await trpc.hsk.registerInitial.mutate({
|
|
||||||
mekVersion: 1,
|
|
||||||
hsk: hmacSecretWrapped,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const downloadHmacSecrets = async () => {
|
|
||||||
let hmacSecretsWrapped;
|
|
||||||
try {
|
|
||||||
hmacSecretsWrapped = await trpc.hsk.list.query();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hmacSecrets = await Promise.all(
|
|
||||||
hmacSecretsWrapped.map(async ({ version, state, mekVersion, hsk: hmacSecretWrapped }) => {
|
|
||||||
const { hmacSecret } = await unwrapHmacSecret(
|
|
||||||
hmacSecretWrapped,
|
|
||||||
keyring.getMasterKey(mekVersion).key,
|
|
||||||
);
|
|
||||||
return { version, state, secret: hmacSecret };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await keyring.setHmacSecrets(hmacSecrets);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Downloads the master keys (and HMAC secrets) once this client has been approved
|
|
||||||
export const downloadMasterKeys = async () => {
|
|
||||||
const { decryptKey, verifyKey } = keyring.requireClientKeys();
|
|
||||||
|
|
||||||
let masterKeysWrapped;
|
|
||||||
try {
|
|
||||||
masterKeysWrapped = await trpc.mek.list.query();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const masterKeys = await Promise.all(
|
|
||||||
masterKeysWrapped.map(
|
|
||||||
async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => {
|
|
||||||
const { masterKey } = await unwrapMasterKey(masterKeyWrapped, decryptKey);
|
|
||||||
return {
|
|
||||||
version,
|
|
||||||
state,
|
|
||||||
key: masterKey,
|
|
||||||
isValid: await verifyMasterKeyWrapped(
|
|
||||||
masterKeyWrapped,
|
|
||||||
version,
|
|
||||||
masterKeyWrappedSig,
|
|
||||||
verifyKey,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (masterKeys.length === 0 || !masterKeys.every(({ isValid }) => isValid)) return false;
|
|
||||||
|
|
||||||
await keyring.setMasterKeys(
|
|
||||||
masterKeys.map(({ version, state, key }) => ({ version, state, key })),
|
|
||||||
);
|
|
||||||
await downloadHmacSecrets();
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ensureHmacSecrets = async () => {
|
|
||||||
if (!keyring.hasHmacSecrets && !(await downloadHmacSecrets())) {
|
|
||||||
throw new Error("Failed to download HMAC secrets");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export interface ClientKeys {
|
|
||||||
encryptKey: CryptoKey;
|
|
||||||
decryptKey: CryptoKey;
|
|
||||||
signKey: CryptoKey;
|
|
||||||
verifyKey: CryptoKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MasterKey {
|
|
||||||
version: number;
|
|
||||||
state: "active" | "retired";
|
|
||||||
key: CryptoKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HmacSecret {
|
|
||||||
version: number;
|
|
||||||
state: "active";
|
|
||||||
secret: CryptoKey;
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import ExifReader from "exifreader";
|
|
||||||
|
|
||||||
const isInteger = (value: number | undefined): value is number => Number.isInteger(value);
|
|
||||||
|
|
||||||
export const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
|
|
||||||
const exif = ExifReader.load(fileBuffer);
|
|
||||||
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
|
|
||||||
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
|
|
||||||
if (!dateTimeOriginal) return undefined;
|
|
||||||
|
|
||||||
const [date, time] = dateTimeOriginal.split(" ");
|
|
||||||
if (!date || !time) return undefined;
|
|
||||||
|
|
||||||
const [year, month, day] = date.split(":").map(Number);
|
|
||||||
const [hour, minute, second] = time.split(":").map(Number);
|
|
||||||
// Hours, minutes, and seconds may be 0, so they are checked differently
|
|
||||||
if (!year || !month || !day || !isInteger(hour) || !isInteger(minute) || !isInteger(second)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!offsetTimeOriginal) {
|
|
||||||
// No timezone information.. Assume local timezone
|
|
||||||
return new Date(year, month - 1, day, hour, minute, second);
|
|
||||||
}
|
|
||||||
|
|
||||||
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
|
|
||||||
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
|
|
||||||
|
|
||||||
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
||||||
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
|
|
||||||
return new Date(utcDate - offsetMs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EXIF_SEARCH_SIZE = 1024 * 1024; // 1 MiB
|
|
||||||
const MAX_FULL_READ_SIZE = 64 * 1024 * 1024; // 64 MiB
|
|
||||||
|
|
||||||
const tryExtractExifDateTime = (buffer: ArrayBuffer) => {
|
|
||||||
try {
|
|
||||||
return extractExifDateTime(buffer);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// EXIF is usually at the beginning of the file, so the whole file is read only if not found
|
|
||||||
export const readExifDateTime = async (file: Blob) => {
|
|
||||||
const dateTime = tryExtractExifDateTime(await file.slice(0, EXIF_SEARCH_SIZE).arrayBuffer());
|
|
||||||
if (dateTime || file.size <= EXIF_SEARCH_SIZE || file.size > MAX_FULL_READ_SIZE) {
|
|
||||||
return dateTime;
|
|
||||||
}
|
|
||||||
return tryExtractExifDateTime(await file.arrayBuffer());
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// Chromium can't decode HEIC images, so they are converted to be displayed
|
|
||||||
export const convertHeic = async (blob: Blob, toType: "image/png" | "image/jpeg") => {
|
|
||||||
const { default: heic2any } = await import("heic2any");
|
|
||||||
return (await heic2any({ blob, toType })) as Blob;
|
|
||||||
};
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { convertHeic } from "./heic.ts";
|
|
||||||
|
|
||||||
const scaleSize = (width: number, height: number, targetSize: number) => {
|
|
||||||
if (width <= targetSize || height <= targetSize) {
|
|
||||||
return { width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
const scale = targetSize / Math.min(width, height);
|
|
||||||
return {
|
|
||||||
width: Math.round(width * scale),
|
|
||||||
height: Math.round(height * scale),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const capture = (
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
drawer: (context: CanvasRenderingContext2D, width: number, height: number) => void,
|
|
||||||
targetSize = 250,
|
|
||||||
) => {
|
|
||||||
return new Promise<Blob>((resolve, reject) => {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
const { width: scaledWidth, height: scaledHeight } = scaleSize(width, height, targetSize);
|
|
||||||
|
|
||||||
canvas.width = scaledWidth;
|
|
||||||
canvas.height = scaledHeight;
|
|
||||||
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) {
|
|
||||||
return reject(new Error("Failed to generate thumbnail"));
|
|
||||||
}
|
|
||||||
|
|
||||||
drawer(context, scaledWidth, scaledHeight);
|
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
if (blob && blob.type === "image/webp") {
|
|
||||||
resolve(blob);
|
|
||||||
} else {
|
|
||||||
reject(new Error("Failed to generate thumbnail"));
|
|
||||||
}
|
|
||||||
}, "image/webp");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateImageThumbnail = (imageUrl: string) => {
|
|
||||||
return new Promise<Blob>((resolve, reject) => {
|
|
||||||
const image = new Image();
|
|
||||||
image.onload = () => {
|
|
||||||
capture(image.width, image.height, (context, width, height) => {
|
|
||||||
context.drawImage(image, 0, 0, width, height);
|
|
||||||
})
|
|
||||||
.then(resolve)
|
|
||||||
.catch(reject);
|
|
||||||
};
|
|
||||||
image.onerror = reject;
|
|
||||||
image.src = imageUrl;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const captureVideoThumbnail = (video: HTMLVideoElement) => {
|
|
||||||
return capture(video.videoWidth, video.videoHeight, (context, width, height) => {
|
|
||||||
context.drawImage(video, 0, 0, width, height);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const VIDEO_THUMBNAIL_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
const generateVideoThumbnail = (videoUrl: string, time = 0) => {
|
|
||||||
return new Promise<Blob>((resolve, reject) => {
|
|
||||||
const video = document.createElement("video");
|
|
||||||
let isCaptured = false;
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
video.onloadedmetadata = video.onloadeddata = video.onseeked = video.onerror = null;
|
|
||||||
video.removeAttribute("src");
|
|
||||||
video.load(); // Stops loading the video
|
|
||||||
};
|
|
||||||
const fail = (reason: unknown) => {
|
|
||||||
cleanup();
|
|
||||||
reject(reason);
|
|
||||||
};
|
|
||||||
const captureFrame = () => {
|
|
||||||
if (isCaptured || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
|
|
||||||
isCaptured = true;
|
|
||||||
captureVideoThumbnail(video).then(resolve, reject).finally(cleanup);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Streamed videos may stall without any error
|
|
||||||
const timeout = setTimeout(
|
|
||||||
() => fail(new Error("Timed out while loading the video")),
|
|
||||||
VIDEO_THUMBNAIL_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
video.onloadedmetadata = () => {
|
|
||||||
if (video.videoWidth === 0 || video.videoHeight === 0) {
|
|
||||||
return fail(new Error("Invalid video size"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frame callbacks aren't guaranteed for videos not in the document, so also wait for seeking
|
|
||||||
video.requestVideoFrameCallback(captureFrame);
|
|
||||||
video.onseeked = captureFrame;
|
|
||||||
if (time === 0) {
|
|
||||||
video.onloadeddata = captureFrame;
|
|
||||||
}
|
|
||||||
video.currentTime = Math.min(time, video.duration);
|
|
||||||
};
|
|
||||||
video.onerror = () => fail(video.error);
|
|
||||||
|
|
||||||
video.muted = true;
|
|
||||||
video.playsInline = true;
|
|
||||||
video.preload = "auto";
|
|
||||||
video.src = videoUrl;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generates a thumbnail of the image or video at the URL. Returns `null` if not supported.
|
|
||||||
export const generateThumbnailFromUrl = async (url: string, contentType: string) => {
|
|
||||||
try {
|
|
||||||
if (contentType.startsWith("image/")) {
|
|
||||||
try {
|
|
||||||
return await generateImageThumbnail(url);
|
|
||||||
} catch {
|
|
||||||
if (contentType !== "image/heic") return null;
|
|
||||||
|
|
||||||
const blob = await (await fetch(url)).blob();
|
|
||||||
const png = URL.createObjectURL(await convertHeic(blob, "image/png"));
|
|
||||||
try {
|
|
||||||
return await generateImageThumbnail(png);
|
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(png);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (contentType.startsWith("video/")) {
|
|
||||||
return await generateVideoThumbnail(url);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateThumbnail = async (blob: Blob) => {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
try {
|
|
||||||
return await generateThumbnailFromUrl(url, blob.type);
|
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { LRUCache } from "lru-cache";
|
|
||||||
import { handleDownloadProgress } from "#lib/client/transfer/download.svelte.ts";
|
|
||||||
import {
|
|
||||||
DECRYPTED_FILE_URL_PREFIX,
|
|
||||||
MAX_PREPARED_FILES,
|
|
||||||
type FileMetadata,
|
|
||||||
type ServiceWorkerMessage,
|
|
||||||
type ServiceWorkerResponse,
|
|
||||||
} from "#lib/shared/service-worker.ts";
|
|
||||||
|
|
||||||
const PREPARE_TIMEOUT_MS = 5000;
|
|
||||||
|
|
||||||
// The service worker may be terminated at any time, losing its in-memory metadata.
|
|
||||||
// Keep a copy here so that it can be re-requested on demand.
|
|
||||||
const preparedFiles = new LRUCache<number, FileMetadata>({ max: MAX_PREPARED_FILES });
|
|
||||||
let isListening = false;
|
|
||||||
|
|
||||||
export const listenForServiceWorkerMessages = () => {
|
|
||||||
if (isListening) return;
|
|
||||||
isListening = true;
|
|
||||||
|
|
||||||
navigator.serviceWorker.addEventListener(
|
|
||||||
"message",
|
|
||||||
(event: MessageEvent<ServiceWorkerResponse>) => {
|
|
||||||
switch (event.data.type) {
|
|
||||||
case "decryption-metadata-request":
|
|
||||||
event.ports[0]?.postMessage(preparedFiles.get(event.data.fileId) ?? null);
|
|
||||||
break;
|
|
||||||
case "download-progress":
|
|
||||||
handleDownloadProgress(event.data);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getServiceWorker = async () => {
|
|
||||||
const registration = await navigator.serviceWorker.ready;
|
|
||||||
const sw = registration.active;
|
|
||||||
if (!sw) {
|
|
||||||
throw new Error("Service worker not activated");
|
|
||||||
}
|
|
||||||
return sw;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const prepareFileDecryption = async (id: number, metadata: FileMetadata) => {
|
|
||||||
preparedFiles.set(id, metadata);
|
|
||||||
listenForServiceWorkerMessages();
|
|
||||||
|
|
||||||
const sw = await getServiceWorker();
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(
|
|
||||||
() => reject(new Error("Service worker timeout")),
|
|
||||||
PREPARE_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
const handler = (event: MessageEvent<ServiceWorkerResponse>) => {
|
|
||||||
if (event.data.type === "decryption-ready" && event.data.fileId === id) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
navigator.serviceWorker.removeEventListener("message", handler);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
navigator.serviceWorker.addEventListener("message", handler);
|
|
||||||
|
|
||||||
sw.postMessage({
|
|
||||||
type: "decryption-prepare",
|
|
||||||
fileId: id,
|
|
||||||
...metadata,
|
|
||||||
} satisfies ServiceWorkerMessage);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDecryptedFileUrl = (id: number) => `${DECRYPTED_FILE_URL_PREFIX}${id}`;
|
|
||||||
|
|
||||||
// Lets the service worker download, decrypt, and save the file
|
|
||||||
export const saveDecryptedFile = (id: number, filename: string) => {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = `${getDecryptedFileUrl(id)}?download=${encodeURIComponent(filename)}`;
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Dexie, type EntityTable } from "dexie";
|
|
||||||
|
|
||||||
export interface FileCacheIndex {
|
|
||||||
fileId: number;
|
|
||||||
cachedAt: Date;
|
|
||||||
lastRetrievedAt: Date;
|
|
||||||
// Files are cached in chunks, possibly partially (e.g. videos which were partially played)
|
|
||||||
size: number;
|
|
||||||
totalSize: number;
|
|
||||||
chunkCount: number;
|
|
||||||
isComplete: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheIndex = new Dexie("cacheIndex") as Dexie & {
|
|
||||||
fileCache: EntityTable<FileCacheIndex, "fileId">;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Files were cached as single files before version 2
|
|
||||||
cacheIndex
|
|
||||||
.version(2)
|
|
||||||
.stores({
|
|
||||||
fileCache: "fileId",
|
|
||||||
})
|
|
||||||
.upgrade(async (trx) => {
|
|
||||||
await trx.table("fileCache").clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getFileCacheIndex = async () => {
|
|
||||||
return await cacheIndex.fileCache.toArray();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFileCacheIndexOf = async (fileId: number) => {
|
|
||||||
return await cacheIndex.fileCache.get(fileId);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => {
|
|
||||||
await cacheIndex.fileCache.put(fileCacheIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateFileCacheRetrievedAt = async (fileId: number, lastRetrievedAt: Date) => {
|
|
||||||
await cacheIndex.fileCache.update(fileId, { lastRetrievedAt });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteFileCacheIndex = async (fileId: number) => {
|
|
||||||
await cacheIndex.fileCache.delete(fileId);
|
|
||||||
};
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
import { Dexie, type EntityTable } from "dexie";
|
|
||||||
|
|
||||||
interface DirectoryInfo {
|
|
||||||
id: number;
|
|
||||||
parentId: DirectoryId;
|
|
||||||
name: string;
|
|
||||||
isFavorite: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileInfo {
|
|
||||||
id: number;
|
|
||||||
parentId: DirectoryId;
|
|
||||||
name: string;
|
|
||||||
contentType: string;
|
|
||||||
createdAt?: Date;
|
|
||||||
lastModifiedAt: Date;
|
|
||||||
categoryIds?: number[];
|
|
||||||
isFavorite: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CategoryInfo {
|
|
||||||
id: number;
|
|
||||||
parentId: CategoryId;
|
|
||||||
name: string;
|
|
||||||
files?: { id: number; isRecursive: boolean }[];
|
|
||||||
isFileRecursive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filesystem = new Dexie("filesystem") as Dexie & {
|
|
||||||
directory: EntityTable<DirectoryInfo, "id">;
|
|
||||||
file: EntityTable<FileInfo, "id">;
|
|
||||||
category: EntityTable<CategoryInfo, "id">;
|
|
||||||
};
|
|
||||||
|
|
||||||
filesystem
|
|
||||||
.version(3)
|
|
||||||
.stores({
|
|
||||||
directory: "id, parentId",
|
|
||||||
file: "id, parentId",
|
|
||||||
category: "id, parentId",
|
|
||||||
})
|
|
||||||
.upgrade(async (trx) => {
|
|
||||||
await trx
|
|
||||||
.table("category")
|
|
||||||
.toCollection()
|
|
||||||
.modify((category) => {
|
|
||||||
category.isFileRecursive = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
filesystem.version(4).upgrade(async (trx) => {
|
|
||||||
await Promise.all([
|
|
||||||
trx
|
|
||||||
.table("directory")
|
|
||||||
.toCollection()
|
|
||||||
.modify((directory) => {
|
|
||||||
directory.isFavorite = false;
|
|
||||||
}),
|
|
||||||
trx
|
|
||||||
.table("file")
|
|
||||||
.toCollection()
|
|
||||||
.modify((file) => {
|
|
||||||
file.isFavorite = false;
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getDirectoryInfos = async (parentId: DirectoryId) => {
|
|
||||||
return await filesystem.directory.where({ parentId }).toArray();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDirectoryInfo = async (id: number) => {
|
|
||||||
return await filesystem.directory.get(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
|
|
||||||
await filesystem.directory.upsert(directoryInfo.id, {
|
|
||||||
parentId: directoryInfo.parentId,
|
|
||||||
name: directoryInfo.name,
|
|
||||||
isFavorite: directoryInfo.isFavorite,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteDirectoryInfo = async (id: number) => {
|
|
||||||
await filesystem.directory.delete(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteDanglingDirectoryInfos = async (
|
|
||||||
parentId: DirectoryId,
|
|
||||||
validIds: Set<number>,
|
|
||||||
) => {
|
|
||||||
await filesystem.directory
|
|
||||||
.where({ parentId })
|
|
||||||
.and((directory) => !validIds.has(directory.id))
|
|
||||||
.delete();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllFileInfos = async () => {
|
|
||||||
return await filesystem.file.toArray();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFileInfos = async (parentId: DirectoryId) => {
|
|
||||||
return await filesystem.file.where({ parentId }).toArray();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFileInfo = async (id: number) => {
|
|
||||||
return await filesystem.file.get(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bulkGetFileInfos = async (ids: number[]) => {
|
|
||||||
return await filesystem.file.bulkGet(ids);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const storeFileInfo = async (fileInfo: FileInfo) => {
|
|
||||||
await filesystem.file.upsert(fileInfo.id, {
|
|
||||||
parentId: fileInfo.parentId,
|
|
||||||
name: fileInfo.name,
|
|
||||||
contentType: fileInfo.contentType,
|
|
||||||
createdAt: fileInfo.createdAt,
|
|
||||||
lastModifiedAt: fileInfo.lastModifiedAt,
|
|
||||||
categoryIds: fileInfo.categoryIds,
|
|
||||||
isFavorite: fileInfo.isFavorite,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteFileInfo = async (id: number) => {
|
|
||||||
await filesystem.file.delete(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bulkDeleteFileInfos = async (ids: number[]) => {
|
|
||||||
await filesystem.file.bulkDelete(ids);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteDanglingFileInfos = async (parentId: DirectoryId, validIds: Set<number>) => {
|
|
||||||
await filesystem.file
|
|
||||||
.where({ parentId })
|
|
||||||
.and((file) => !validIds.has(file.id))
|
|
||||||
.delete();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCategoryInfos = async (parentId: CategoryId) => {
|
|
||||||
return await filesystem.category.where({ parentId }).toArray();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCategoryInfo = async (id: number) => {
|
|
||||||
return await filesystem.category.get(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
|
|
||||||
await filesystem.category.upsert(categoryInfo.id, {
|
|
||||||
parentId: categoryInfo.parentId,
|
|
||||||
name: categoryInfo.name,
|
|
||||||
files: categoryInfo.files,
|
|
||||||
isFileRecursive: categoryInfo.isFileRecursive,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
|
|
||||||
await filesystem.category.update(id, changes);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteCategoryInfo = async (id: number) => {
|
|
||||||
await filesystem.category.delete(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteDanglingCategoryInfos = async (parentId: CategoryId, validIds: Set<number>) => {
|
|
||||||
await filesystem.category
|
|
||||||
.where({ parentId })
|
|
||||||
.and((category) => !validIds.has(category.id))
|
|
||||||
.delete();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const cleanupDanglingInfos = async () => {
|
|
||||||
const validDirectoryIds: number[] = [];
|
|
||||||
const validFileIds: number[] = [];
|
|
||||||
const directoryQueue: DirectoryId[] = ["root"];
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const directoryId = directoryQueue.shift();
|
|
||||||
if (!directoryId) break;
|
|
||||||
|
|
||||||
const [subDirectories, files] = await Promise.all([
|
|
||||||
filesystem.directory.where({ parentId: directoryId }).toArray(),
|
|
||||||
filesystem.file.where({ parentId: directoryId }).toArray(),
|
|
||||||
]);
|
|
||||||
subDirectories.forEach(({ id }) => {
|
|
||||||
validDirectoryIds.push(id);
|
|
||||||
directoryQueue.push(id);
|
|
||||||
});
|
|
||||||
files.forEach(({ id }) => validFileIds.push(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const validCategoryIds: number[] = [];
|
|
||||||
const categoryQueue: CategoryId[] = ["root"];
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const categoryId = categoryQueue.shift();
|
|
||||||
if (!categoryId) break;
|
|
||||||
|
|
||||||
const subCategories = await filesystem.category.where({ parentId: categoryId }).toArray();
|
|
||||||
subCategories.forEach(({ id }) => {
|
|
||||||
validCategoryIds.push(id);
|
|
||||||
categoryQueue.push(id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
filesystem.directory.where("id").noneOf(validDirectoryIds).delete(),
|
|
||||||
filesystem.file.where("id").noneOf(validFileIds).delete(),
|
|
||||||
filesystem.category.where("id").noneOf(validCategoryIds).delete(),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from "./cache-index.ts";
|
|
||||||
export * from "./filesystem.ts";
|
|
||||||
export * from "./key-store.ts";
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
const getFileHandle = async (path: string, create = true) => {
|
|
||||||
if (path[0] !== "/") {
|
|
||||||
throw new Error("Path must be absolute");
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = path.split("/");
|
|
||||||
if (parts.length <= 1) {
|
|
||||||
throw new Error("Invalid path");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let directoryHandle = await navigator.storage.getDirectory();
|
|
||||||
for (const part of parts.slice(0, -1)) {
|
|
||||||
if (!part) continue;
|
|
||||||
directoryHandle = await directoryHandle.getDirectoryHandle(part, { create });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = parts[parts.length - 1]!;
|
|
||||||
const fileHandle = await directoryHandle.getFileHandle(filename, { create });
|
|
||||||
return { parentHandle: directoryHandle, filename, fileHandle };
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof DOMException && e.name === "NotFoundError") {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFile = async (path: string) => {
|
|
||||||
const { fileHandle } = await getFileHandle(path, false);
|
|
||||||
if (!fileHandle) return null;
|
|
||||||
|
|
||||||
return await fileHandle.getFile();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const readFile = async (path: string) => {
|
|
||||||
return (await getFile(path))?.arrayBuffer() ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const writeFile = async (path: string, data: ArrayBuffer) => {
|
|
||||||
const { fileHandle } = await getFileHandle(path);
|
|
||||||
const writable = await fileHandle!.createWritable();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await writable.write(data);
|
|
||||||
} finally {
|
|
||||||
await writable.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Changes are applied atomically when the stream is closed, and discarded if it is aborted
|
|
||||||
export const createWritable = async (path: string) => {
|
|
||||||
const { fileHandle } = await getFileHandle(path);
|
|
||||||
return await fileHandle!.createWritable();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteFile = async (path: string) => {
|
|
||||||
const { parentHandle, filename } = await getFileHandle(path, false);
|
|
||||||
if (!parentHandle) return;
|
|
||||||
|
|
||||||
await parentHandle.removeEntry(filename);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDirectoryHandle = async (path: string) => {
|
|
||||||
if (path[0] !== "/") {
|
|
||||||
throw new Error("Path must be absolute");
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = path.split("/");
|
|
||||||
if (parts.length <= 1) {
|
|
||||||
throw new Error("Invalid path");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let directoryHandle = await navigator.storage.getDirectory();
|
|
||||||
let parentHandle;
|
|
||||||
for (const part of parts.slice(1)) {
|
|
||||||
if (!part) continue;
|
|
||||||
parentHandle = directoryHandle;
|
|
||||||
directoryHandle = await directoryHandle.getDirectoryHandle(part);
|
|
||||||
}
|
|
||||||
return { directoryHandle, parentHandle };
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof DOMException && e.name === "NotFoundError") {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteDirectory = async (path: string) => {
|
|
||||||
const { directoryHandle, parentHandle } = await getDirectoryHandle(path);
|
|
||||||
if (!parentHandle) return;
|
|
||||||
|
|
||||||
await parentHandle.removeEntry(directoryHandle.name, { recursive: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const listEntryNames = async (path: string, kind: FileSystemHandleKind) => {
|
|
||||||
const { directoryHandle } = await getDirectoryHandle(path);
|
|
||||||
if (!directoryHandle) return [];
|
|
||||||
|
|
||||||
const names: string[] = [];
|
|
||||||
for await (const handle of directoryHandle.values()) {
|
|
||||||
if (handle.kind === kind) {
|
|
||||||
names.push(handle.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return names;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listFileNames = (path: string) => listEntryNames(path, "file");
|
|
||||||
|
|
||||||
export const listDirectoryNames = (path: string) => listEntryNames(path, "directory");
|
|
||||||
|
|
||||||
// Deletes the files (not directories) directly under the directory
|
|
||||||
export const deleteFilesInDirectory = async (path: string) => {
|
|
||||||
const { directoryHandle } = await getDirectoryHandle(path);
|
|
||||||
if (!directoryHandle) return;
|
|
||||||
|
|
||||||
for (const name of await listFileNames(path)) {
|
|
||||||
await directoryHandle.removeEntry(name);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Renames the file atomically
|
|
||||||
export const moveFile = async (path: string, newPath: string) => {
|
|
||||||
const { fileHandle } = await getFileHandle(path, false);
|
|
||||||
if (!fileHandle) {
|
|
||||||
throw new Error("File not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
let directoryHandle = await navigator.storage.getDirectory();
|
|
||||||
const parts = newPath.split("/").filter((part) => part);
|
|
||||||
for (const part of parts.slice(0, -1)) {
|
|
||||||
directoryHandle = await directoryHandle.getDirectoryHandle(part, { create: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supported by Chromium for the origin private file system
|
|
||||||
await (
|
|
||||||
fileHandle as FileSystemFileHandle & {
|
|
||||||
move(destination: FileSystemDirectoryHandle, name: string): Promise<void>;
|
|
||||||
}
|
|
||||||
).move(directoryHandle, parts.at(-1)!);
|
|
||||||
};
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import { sha256 } from "@noble/hashes/sha2.js";
|
|
||||||
import axios from "axios";
|
|
||||||
import { encodeToBase64, encryptChunk } from "#lib/shared/crypto/index.ts";
|
|
||||||
import { CHUNK_SIZE, getChunkCount, getEncryptedSize } from "#lib/shared/format/index.ts";
|
|
||||||
import { BoundedQueue } from "#lib/utils/concurrency/index.ts";
|
|
||||||
import { createSpeedMeter } from "#lib/utils/speed-meter.ts";
|
|
||||||
|
|
||||||
interface UploadStats {
|
|
||||||
progress: number;
|
|
||||||
rate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EncryptedChunk {
|
|
||||||
index: number;
|
|
||||||
data: ArrayBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadEncryptedChunk = async (
|
|
||||||
uploadId: string,
|
|
||||||
chunkIndex: number,
|
|
||||||
encrypted: ArrayBuffer,
|
|
||||||
onChunkProgress: (chunkIndex: number, loaded: number) => void,
|
|
||||||
) => {
|
|
||||||
await axios.post(`/api/upload/${uploadId}/chunks/${chunkIndex + 1}`, encrypted, {
|
|
||||||
headers: { "Content-Type": "application/octet-stream" },
|
|
||||||
onUploadProgress(e) {
|
|
||||||
onChunkProgress(chunkIndex, e.loaded ?? 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
onChunkProgress(chunkIndex, encrypted.byteLength);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uploadBlob = async (
|
|
||||||
uploadId: string,
|
|
||||||
blob: Blob,
|
|
||||||
dataKey: CryptoKey,
|
|
||||||
options?: {
|
|
||||||
concurrency?: number;
|
|
||||||
onProgress?: (s: UploadStats) => void;
|
|
||||||
// Receives each chunk of the plaintext before it is encrypted
|
|
||||||
onPlaintextChunk?: (chunkIndex: number, data: ArrayBuffer) => Promise<void>;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const onProgress = options?.onProgress;
|
|
||||||
const networkConcurrency = options?.concurrency ?? 4;
|
|
||||||
const maxQueueSize = 8;
|
|
||||||
|
|
||||||
const totalChunks = getChunkCount(blob.size);
|
|
||||||
const totalBytes = getEncryptedSize(blob.size);
|
|
||||||
|
|
||||||
const uploadedByChunk = new Array<number>(totalChunks).fill(0);
|
|
||||||
const speedMeter = createSpeedMeter(3000, 200);
|
|
||||||
const hash = sha256.create();
|
|
||||||
|
|
||||||
const emit = () => {
|
|
||||||
if (!onProgress) return;
|
|
||||||
|
|
||||||
const uploadedBytes = uploadedByChunk.reduce((a, b) => a + b, 0);
|
|
||||||
const rate = speedMeter(uploadedBytes);
|
|
||||||
const progress = Math.min(1, uploadedBytes / totalBytes);
|
|
||||||
|
|
||||||
onProgress({ progress, rate });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onChunkProgress = (idx: number, loaded: number) => {
|
|
||||||
uploadedByChunk[idx] = loaded;
|
|
||||||
emit();
|
|
||||||
};
|
|
||||||
|
|
||||||
const queue = new BoundedQueue<EncryptedChunk>(maxQueueSize);
|
|
||||||
|
|
||||||
// Producer: encrypt chunks and push to queue
|
|
||||||
const encryptionProducer = async () => {
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < totalChunks; i++) {
|
|
||||||
const chunk = await blob.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE).arrayBuffer();
|
|
||||||
await options?.onPlaintextChunk?.(i, chunk);
|
|
||||||
const data = await encryptChunk(chunk, dataKey);
|
|
||||||
hash.update(new Uint8Array(data));
|
|
||||||
await queue.push({ index: i, data });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
queue.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Consumer: upload chunks from queue with concurrency limit
|
|
||||||
const uploadConsumer = async () => {
|
|
||||||
const activeTasks = new Set<Promise<void>>();
|
|
||||||
let failure: { reason: unknown } | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (!failure) {
|
|
||||||
const item = await queue.pop();
|
|
||||||
if (item === null) break;
|
|
||||||
|
|
||||||
const task: Promise<void> = uploadEncryptedChunk(
|
|
||||||
uploadId,
|
|
||||||
item.index,
|
|
||||||
item.data,
|
|
||||||
onChunkProgress,
|
|
||||||
)
|
|
||||||
.catch((reason) => {
|
|
||||||
failure ??= { reason };
|
|
||||||
})
|
|
||||||
.finally(() => activeTasks.delete(task));
|
|
||||||
activeTasks.add(task);
|
|
||||||
|
|
||||||
if (activeTasks.size >= networkConcurrency) {
|
|
||||||
await Promise.race(activeTasks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(activeTasks);
|
|
||||||
if (failure) throw failure.reason;
|
|
||||||
} finally {
|
|
||||||
queue.close(); // Stops the producer if the upload failed
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run producer and consumer concurrently
|
|
||||||
const [encryption, upload] = await Promise.allSettled([encryptionProducer(), uploadConsumer()]);
|
|
||||||
// The upload error takes precedence, as the producer fails due to the closed queue then
|
|
||||||
if (upload.status === "rejected") throw upload.reason;
|
|
||||||
if (encryption.status === "rejected") throw encryption.reason;
|
|
||||||
|
|
||||||
onProgress?.({ progress: 1, rate: speedMeter() });
|
|
||||||
return { encContentHash: encodeToBase64(hash.digest()) };
|
|
||||||
};
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import type { DownloadProgressMessage } from "#lib/shared/service-worker.ts";
|
|
||||||
import { createSpeedMeter } from "#lib/utils/speed-meter.ts";
|
|
||||||
|
|
||||||
// Files are downloaded and decrypted by the service worker, which reports the progress
|
|
||||||
|
|
||||||
export interface FileDownloadState {
|
|
||||||
id: number;
|
|
||||||
status: "downloading" | "completed" | "error";
|
|
||||||
progress?: number;
|
|
||||||
rate?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let downloadingFiles: FileDownloadState[] = $state([]);
|
|
||||||
const speedMeters = new WeakMap<FileDownloadState, ReturnType<typeof createSpeedMeter>>();
|
|
||||||
|
|
||||||
export const isFileDownloading = (status: FileDownloadState["status"]) => {
|
|
||||||
return status === "downloading";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFileDownloadState = (fileId: number) => {
|
|
||||||
return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDownloadingFiles = () => {
|
|
||||||
return downloadingFiles.filter((file) => isFileDownloading(file.status));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearDownloadedFiles = () => {
|
|
||||||
downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const handleDownloadProgress = ({
|
|
||||||
fileId,
|
|
||||||
status,
|
|
||||||
loaded,
|
|
||||||
total,
|
|
||||||
}: DownloadProgressMessage) => {
|
|
||||||
let state = getFileDownloadState(fileId);
|
|
||||||
if (!state) {
|
|
||||||
if (status !== "downloading") return;
|
|
||||||
|
|
||||||
downloadingFiles.push({ id: fileId, status });
|
|
||||||
state = downloadingFiles.at(-1)!;
|
|
||||||
speedMeters.set(state, createSpeedMeter());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "canceled") {
|
|
||||||
downloadingFiles = downloadingFiles.filter((file) => file !== state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.status = status;
|
|
||||||
state.progress = total > 0 ? loaded / total : 1;
|
|
||||||
state.rate = speedMeters.get(state)?.(loaded);
|
|
||||||
};
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./download.svelte.ts";
|
|
||||||
export * from "./upload.svelte.ts";
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import { limitFunction } from "p-limit";
|
|
||||||
import { createUploadFileCache } from "#lib/client/cache/index.ts";
|
|
||||||
import { signMessageHmac } from "#lib/client/crypto/hmac.ts";
|
|
||||||
import {
|
|
||||||
ensureHmacSecrets,
|
|
||||||
keyring,
|
|
||||||
type HmacSecret,
|
|
||||||
type MasterKey,
|
|
||||||
} from "#lib/client/keys/index.ts";
|
|
||||||
import { readExifDateTime } from "#lib/client/media/exif.ts";
|
|
||||||
import { generateThumbnail } from "#lib/client/media/thumbnail.ts";
|
|
||||||
import { uploadBlob } from "#lib/client/transfer/chunked-upload.ts";
|
|
||||||
import {
|
|
||||||
encodeToBase64,
|
|
||||||
generateDataKey,
|
|
||||||
wrapDataKey,
|
|
||||||
encryptString,
|
|
||||||
} from "#lib/shared/crypto/index.ts";
|
|
||||||
import { getChunkCount } from "#lib/shared/format/index.ts";
|
|
||||||
import { Scheduler } from "#lib/utils/index.ts";
|
|
||||||
import { trpc } from "#trpc/client.ts";
|
|
||||||
|
|
||||||
const LIVE_STATUSES = [
|
|
||||||
"queued",
|
|
||||||
"encryption-pending",
|
|
||||||
"encrypting",
|
|
||||||
"upload-pending",
|
|
||||||
"uploading",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type LiveStatus = (typeof LIVE_STATUSES)[number];
|
|
||||||
|
|
||||||
export interface FileUploadState {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
parentId: DirectoryId;
|
|
||||||
status: LiveStatus | "uploaded" | "canceled" | "error";
|
|
||||||
progress?: number;
|
|
||||||
rate?: number;
|
|
||||||
estimated?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LiveFileUploadState = FileUploadState & { status: LiveStatus };
|
|
||||||
|
|
||||||
const scheduler = new Scheduler<{ fileId: number; thumbnailBuffer?: ArrayBuffer } | undefined>();
|
|
||||||
let uploadingFiles: FileUploadState[] = $state([]);
|
|
||||||
|
|
||||||
const isFileUploading = (file: FileUploadState): file is LiveFileUploadState => {
|
|
||||||
return (LIVE_STATUSES as readonly string[]).includes(file.status);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUploadingFiles = (parentId?: DirectoryId) => {
|
|
||||||
return uploadingFiles
|
|
||||||
.filter(isFileUploading)
|
|
||||||
.filter((file) => parentId === undefined || file.parentId === parentId);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearUploadedFiles = () => {
|
|
||||||
uploadingFiles = uploadingFiles.filter(isFileUploading);
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestDuplicateFileScan = limitFunction(
|
|
||||||
async (
|
|
||||||
state: FileUploadState,
|
|
||||||
file: File,
|
|
||||||
hmacSecret: HmacSecret,
|
|
||||||
onDuplicate: () => Promise<boolean>,
|
|
||||||
) => {
|
|
||||||
state.status = "encryption-pending";
|
|
||||||
|
|
||||||
const fileSigned = encodeToBase64(await signMessageHmac(file, hmacSecret.secret));
|
|
||||||
const files = await trpc.file.listByHash.query({
|
|
||||||
hskVersion: hmacSecret.version,
|
|
||||||
contentHmac: fileSigned,
|
|
||||||
});
|
|
||||||
if (files.length === 0 || (await onDuplicate())) {
|
|
||||||
return { fileSigned };
|
|
||||||
} else {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ concurrency: 1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const getFileType = (file: File) => {
|
|
||||||
if (file.type) return file.type;
|
|
||||||
if (file.name.endsWith(".heic")) return "image/heic";
|
|
||||||
throw new Error("Unknown file type");
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FileMetadata {
|
|
||||||
parentId: "root" | number;
|
|
||||||
name: string;
|
|
||||||
createdAt?: Date;
|
|
||||||
lastModifiedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestFileMetadataEncryption = limitFunction(
|
|
||||||
async (
|
|
||||||
state: FileUploadState,
|
|
||||||
file: Blob,
|
|
||||||
fileMetadata: FileMetadata,
|
|
||||||
masterKey: MasterKey,
|
|
||||||
hmacSecret: HmacSecret,
|
|
||||||
) => {
|
|
||||||
state.status = "encrypting";
|
|
||||||
|
|
||||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
|
||||||
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
|
|
||||||
|
|
||||||
const [nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, thumbnailBuffer] =
|
|
||||||
await Promise.all([
|
|
||||||
encryptString(fileMetadata.name, dataKey),
|
|
||||||
fileMetadata.createdAt &&
|
|
||||||
encryptString(fileMetadata.createdAt.getTime().toString(), dataKey),
|
|
||||||
encryptString(fileMetadata.lastModifiedAt.getTime().toString(), dataKey),
|
|
||||||
generateThumbnail(file).then((blob) => blob?.arrayBuffer()),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { uploadId } = await trpc.upload.startFileUpload.mutate({
|
|
||||||
chunks: getChunkCount(file.size),
|
|
||||||
parent: fileMetadata.parentId,
|
|
||||||
mekVersion: masterKey.version,
|
|
||||||
dek: dataKeyWrapped,
|
|
||||||
dekVersion: dataKeyVersion,
|
|
||||||
hskVersion: hmacSecret.version,
|
|
||||||
contentType: file.type,
|
|
||||||
name: nameEncrypted.ciphertext,
|
|
||||||
nameIv: nameEncrypted.iv,
|
|
||||||
createdAt: createdAtEncrypted?.ciphertext,
|
|
||||||
createdAtIv: createdAtEncrypted?.iv,
|
|
||||||
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
|
|
||||||
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
|
|
||||||
});
|
|
||||||
|
|
||||||
state.status = "upload-pending";
|
|
||||||
return { uploadId, thumbnailBuffer, dataKey, dataKeyVersion };
|
|
||||||
},
|
|
||||||
{ concurrency: 4 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const requestFileUpload = limitFunction(
|
|
||||||
async (
|
|
||||||
state: FileUploadState,
|
|
||||||
uploadId: string,
|
|
||||||
file: Blob,
|
|
||||||
fileSigned: string,
|
|
||||||
thumbnailBuffer: ArrayBuffer | undefined,
|
|
||||||
dataKey: CryptoKey,
|
|
||||||
dataKeyVersion: Date,
|
|
||||||
) => {
|
|
||||||
state.status = "uploading";
|
|
||||||
|
|
||||||
// The file is cached while uploading, so that it doesn't have to be read again
|
|
||||||
const cache = createUploadFileCache(file.size);
|
|
||||||
|
|
||||||
let fileId;
|
|
||||||
try {
|
|
||||||
const { encContentHash } = await uploadBlob(uploadId, file, dataKey, {
|
|
||||||
onProgress(s) {
|
|
||||||
state.progress = s.progress;
|
|
||||||
state.rate = s.rate;
|
|
||||||
},
|
|
||||||
onPlaintextChunk: cache.writeChunk,
|
|
||||||
});
|
|
||||||
|
|
||||||
({ file: fileId } = await trpc.upload.completeFileUpload.mutate({
|
|
||||||
uploadId,
|
|
||||||
contentHmac: fileSigned,
|
|
||||||
encContentHash,
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
await cache.discard();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
await cache.commit(fileId).catch(console.error);
|
|
||||||
|
|
||||||
if (thumbnailBuffer) {
|
|
||||||
try {
|
|
||||||
const { uploadId } = await trpc.upload.startFileThumbnailUpload.mutate({
|
|
||||||
file: fileId,
|
|
||||||
dekVersion: dataKeyVersion,
|
|
||||||
});
|
|
||||||
|
|
||||||
await uploadBlob(uploadId, new Blob([thumbnailBuffer]), dataKey);
|
|
||||||
|
|
||||||
await trpc.upload.completeFileThumbnailUpload.mutate({ uploadId });
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.status = "uploaded";
|
|
||||||
return { fileId };
|
|
||||||
},
|
|
||||||
{ concurrency: 1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
export const uploadFile = async (
|
|
||||||
file: File,
|
|
||||||
parentId: "root" | number,
|
|
||||||
onDuplicate: () => Promise<boolean>,
|
|
||||||
) => {
|
|
||||||
uploadingFiles.push({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
name: file.name,
|
|
||||||
parentId,
|
|
||||||
status: "queued",
|
|
||||||
});
|
|
||||||
const state = uploadingFiles.at(-1)!;
|
|
||||||
|
|
||||||
return await scheduler.schedule(file.size, async () => {
|
|
||||||
try {
|
|
||||||
await ensureHmacSecrets();
|
|
||||||
const masterKey = keyring.activeMasterKey;
|
|
||||||
const hmacSecret = keyring.activeHmacSecret;
|
|
||||||
|
|
||||||
const { fileSigned } = await requestDuplicateFileScan(state, file, hmacSecret, onDuplicate);
|
|
||||||
|
|
||||||
if (!fileSigned) {
|
|
||||||
state.status = "canceled";
|
|
||||||
uploadingFiles = uploadingFiles.filter((file) => file !== state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileType = getFileType(file);
|
|
||||||
const fileMetadata: FileMetadata = {
|
|
||||||
parentId,
|
|
||||||
name: file.name,
|
|
||||||
lastModifiedAt: new Date(file.lastModified),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fileType.startsWith("image/")) {
|
|
||||||
fileMetadata.createdAt = await readExifDateTime(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([file], { type: fileType });
|
|
||||||
|
|
||||||
const { uploadId, thumbnailBuffer, dataKey, dataKeyVersion } =
|
|
||||||
await requestFileMetadataEncryption(state, blob, fileMetadata, masterKey, hmacSecret);
|
|
||||||
|
|
||||||
const { fileId } = await requestFileUpload(
|
|
||||||
state,
|
|
||||||
uploadId,
|
|
||||||
blob,
|
|
||||||
fileSigned,
|
|
||||||
thumbnailBuffer,
|
|
||||||
dataKey,
|
|
||||||
dataKeyVersion,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { fileId, thumbnailBuffer };
|
|
||||||
} catch (e) {
|
|
||||||
state.status = "error";
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { fade, fly } from "svelte/transition";
|
||||||
|
import { AdaptiveDiv } from "$lib/components/divs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
onclose?: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, onclose, isOpen = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
const closeBottomSheet = $derived(
|
||||||
|
onclose ||
|
||||||
|
(() => {
|
||||||
|
isOpen = false;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div onclick={closeBottomSheet} class="fixed inset-0 z-10 flex items-end justify-center">
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-50" transition:fade={{ duration: 100 }}></div>
|
||||||
|
<div class="z-20 w-full">
|
||||||
|
<AdaptiveDiv>
|
||||||
|
<div
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
class="flex max-h-[70vh] min-h-[30vh] rounded-t-2xl bg-white px-4"
|
||||||
|
transition:fly={{ y: 100, duration: 200 }}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</AdaptiveDiv>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import { AdaptiveDiv } from "$lib/components/divs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
onclose?: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, onclose, isOpen = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
const closeModal = $derived(
|
||||||
|
onclose ||
|
||||||
|
(() => {
|
||||||
|
isOpen = false;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
onclick={closeModal}
|
||||||
|
class="fixed inset-0 z-10 bg-black bg-opacity-50"
|
||||||
|
transition:fade={{ duration: 100 }}
|
||||||
|
>
|
||||||
|
<AdaptiveDiv>
|
||||||
|
<div class="flex h-full items-center justify-center px-4">
|
||||||
|
<div onclick={(e) => e.stopPropagation()} class="rounded-2xl bg-white p-4">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdaptiveDiv>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
import IconArrowBack from "~icons/material-symbols/arrow-back";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: Snippet;
|
||||||
|
onback?: () => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, onback, title }: Props = $props();
|
||||||
|
|
||||||
|
const back = $derived(() => {
|
||||||
|
setTimeout(onback || (() => history.back()), 100);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sticky top-0 z-10 flex flex-shrink-0 items-center justify-between bg-white py-4">
|
||||||
|
<button onclick={back} class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-gray-100">
|
||||||
|
<IconArrowBack class="text-2xl" />
|
||||||
|
</button>
|
||||||
|
{#if title}
|
||||||
|
<p class="flex-grow truncate px-2 text-center text-lg font-semibold">{title}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="w-[2.3rem] flex-shrink-0">
|
||||||
|
{#if children}
|
||||||
|
{@render children?.()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
color?: "primary" | "gray";
|
||||||
|
onclick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, color = "primary", onclick }: Props = $props();
|
||||||
|
|
||||||
|
const bgColorStyle = $derived(
|
||||||
|
{
|
||||||
|
primary: "bg-primary-600 active:bg-primary-500",
|
||||||
|
gray: "bg-gray-300 active:bg-gray-400",
|
||||||
|
}[color],
|
||||||
|
);
|
||||||
|
const fontColorStyle = $derived(
|
||||||
|
{
|
||||||
|
primary: "text-white",
|
||||||
|
gray: "text-gray-800",
|
||||||
|
}[color],
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
onclick?.();
|
||||||
|
}, 100);
|
||||||
|
}}
|
||||||
|
class="{bgColorStyle} {fontColorStyle} h-12 w-full min-w-fit rounded-xl font-medium"
|
||||||
|
>
|
||||||
|
<div class="h-full w-full p-3 transition active:scale-95">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
import IconChevronRight from "~icons/material-symbols/chevron-right";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
onclick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, onclick }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
onclick?.();
|
||||||
|
}, 100);
|
||||||
|
}}
|
||||||
|
class="w-full rounded-xl active:bg-gray-100"
|
||||||
|
>
|
||||||
|
<div class="flex w-full justify-between p-2 transition active:scale-95">
|
||||||
|
<div>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<IconChevronRight class="text-xl text-gray-800" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Component } from "svelte";
|
||||||
|
import type { SvelteHTMLElements } from "svelte/elements";
|
||||||
|
import { AdaptiveDiv } from "$lib/components/divs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: Component<SvelteHTMLElements["svg"]>;
|
||||||
|
offset?: string;
|
||||||
|
onclick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { icon: Icon, offset = "bottom-20", onclick }: Props = $props();
|
||||||
|
|
||||||
|
const click = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
onclick?.();
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pointer-events-none fixed inset-0">
|
||||||
|
<div class="absolute w-full {offset}">
|
||||||
|
<AdaptiveDiv>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute bottom-4 right-4">
|
||||||
|
<button
|
||||||
|
onclick={click}
|
||||||
|
class="pointer-events-auto flex h-14 w-14 items-center justify-center rounded-full bg-gray-300 shadow-lg transition active:scale-95 active:bg-gray-400"
|
||||||
|
>
|
||||||
|
<Icon class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdaptiveDiv>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { afterPress } from "../press.ts";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -11,10 +10,14 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={afterPress(onclick)}
|
onclick={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
onclick?.();
|
||||||
|
}, 100);
|
||||||
|
}}
|
||||||
class="text-sm font-medium text-gray-800 underline underline-offset-2 active:rounded-xl active:bg-gray-100"
|
class="text-sm font-medium text-gray-800 underline underline-offset-2 active:rounded-xl active:bg-gray-100"
|
||||||
>
|
>
|
||||||
<div class="h-full p-1 transition active:scale-95">
|
<div class="h-full w-full p-1 transition active:scale-95">
|
||||||
{@render children()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as Button } from "./Button.svelte";
|
||||||
|
export { default as EntryButton } from "./EntryButton.svelte";
|
||||||
|
export { default as FloatingButton } from "./FloatingButton.svelte";
|
||||||
|
export { default as TextButton } from "./TextButton.svelte";
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto h-full w-full max-w-screen-md">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sticky bottom-0 flex flex-col items-center gap-y-2 bg-white pb-4">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Component, Snippet } from "svelte";
|
||||||
|
import type { SvelteHTMLElements } from "svelte/elements";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
icon?: Component<SvelteHTMLElements["svg"]>;
|
||||||
|
topPadding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { topPadding = true, children, icon: Icon }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="box-content flex min-h-[10vh] items-center {topPadding ? 'pt-4' : ''}">
|
||||||
|
{#if Icon}
|
||||||
|
<Icon class="text-5xl text-gray-600" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as AdaptiveDiv } from "./AdaptiveDiv.svelte";
|
||||||
|
export { default as BottomDiv } from "./BottomDiv.svelte";
|
||||||
|
export { default as TitleDiv } from "./TitleDiv.svelte";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as BottomSheet } from "./BottomSheet.svelte";
|
||||||
|
export { default as Modal } from "./Modal.svelte";
|
||||||
|
export { default as TopBar } from "./TopBar.svelte";
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
placeholder: string;
|
||||||
|
type?: "text" | "password";
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { placeholder, type = "text", value = $bindable("") }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative mt-5">
|
||||||
|
<input
|
||||||
|
bind:value
|
||||||
|
{type}
|
||||||
|
placeholder=""
|
||||||
|
class="w-full border-b-2 border-gray-300 py-1 text-xl outline-none transition duration-300 ease-in-out"
|
||||||
|
/>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label
|
||||||
|
class="absolute left-0 top-1/2 -translate-y-1/2 transform text-xl text-gray-400 transition-all duration-300 ease-in-out"
|
||||||
|
>
|
||||||
|
{placeholder}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
input:focus,
|
||||||
|
input:not(:placeholder-shown) {
|
||||||
|
@apply border-primary-300;
|
||||||
|
}
|
||||||
|
input:focus + label,
|
||||||
|
input:not(:placeholder-shown) + label {
|
||||||
|
@apply top-0 -translate-y-full text-sm text-primary-400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as TextInput } from "./TextInput.svelte";
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { ActionModal } from "#lib/ui/index.ts";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean;
|
|
||||||
oncancel?: () => void;
|
|
||||||
onLoginClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { isOpen = $bindable(), oncancel, onLoginClick }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ActionModal
|
|
||||||
bind:isOpen
|
|
||||||
title="다른 디바이스에 이미 로그인되어 있어요."
|
|
||||||
cancelText="아니요"
|
|
||||||
{oncancel}
|
|
||||||
confirmText="네"
|
|
||||||
onConfirmClick={onLoginClick}
|
|
||||||
>
|
|
||||||
<p>다른 디바이스에서는 로그아웃하고, 이 디바이스에서 로그인할까요?</p>
|
|
||||||
</ActionModal>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as ForceLoginModal } from "./ForceLoginModal.svelte";
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { trpc } from "#trpc/client.ts";
|
|
||||||
|
|
||||||
export const login = async (email: string, password: string) => {
|
|
||||||
await trpc.auth.login.mutate({ email, password });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const logout = async () => {
|
|
||||||
await trpc.auth.logout.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const changePassword = async (oldPassword: string, newPassword: string) => {
|
|
||||||
await trpc.auth.changePassword.mutate({ oldPassword, newPassword });
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { showToast } from "#lib/ui/index.ts";
|
|
||||||
import { isTRPCClientError } from "#trpc/client.ts";
|
|
||||||
|
|
||||||
const getErrorMessage = (e: unknown, fallbackMessage: string) => {
|
|
||||||
if (isTRPCClientError(e)) {
|
|
||||||
switch (e.data?.code) {
|
|
||||||
case "NOT_FOUND":
|
|
||||||
return "대상을 찾을 수 없어요. 이미 삭제되었을 수 있어요.";
|
|
||||||
case undefined:
|
|
||||||
return "서버에 연결할 수 없어요.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallbackMessage;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const showErrorToast = (e: unknown, fallbackMessage: string) => {
|
|
||||||
console.error(e);
|
|
||||||
showToast(getErrorMessage(e, fallbackMessage));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Runs the action and shows the error to the user if it fails. Returns whether it succeeded.
|
|
||||||
export const tryAction = async (action: () => Promise<unknown>, errorMessage: string) => {
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
showErrorToast(e, errorMessage);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import {
|
|
||||||
getDirectoryInfo,
|
|
||||||
getFileInfo,
|
|
||||||
isExistingFileInfo,
|
|
||||||
isLocalDirectoryInfo,
|
|
||||||
setDirectoryFavorite,
|
|
||||||
setFileFavorite,
|
|
||||||
type LocalDirectoryInfo,
|
|
||||||
type SummarizedFileInfo,
|
|
||||||
} from "#lib/features/filesystem/index.ts";
|
|
||||||
import { sortEntries } from "#lib/utils/index.ts";
|
|
||||||
import { trpc } from "#trpc/client.ts";
|
|
||||||
|
|
||||||
export type FavoriteEntry =
|
|
||||||
| { type: "directory"; name: string; details: LocalDirectoryInfo }
|
|
||||||
| { type: "file"; name: string; details: SummarizedFileInfo };
|
|
||||||
|
|
||||||
export const getFavoriteEntries = async () => {
|
|
||||||
const favorites = await trpc.favorites.get.query();
|
|
||||||
const [directories, files] = await Promise.all([
|
|
||||||
Promise.all(
|
|
||||||
favorites.directories.map((directory) =>
|
|
||||||
getDirectoryInfo(directory.id, { serverResponse: { ...directory, isFavorite: true } }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Promise.all(
|
|
||||||
favorites.files.map((file) =>
|
|
||||||
getFileInfo(file.id, { serverResponse: { ...file, isFavorite: true } }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
return [
|
|
||||||
...sortEntries(
|
|
||||||
directories.filter(isLocalDirectoryInfo).map((directory): FavoriteEntry => ({
|
|
||||||
type: "directory",
|
|
||||||
name: directory.name,
|
|
||||||
details: directory,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
...sortEntries(
|
|
||||||
files
|
|
||||||
.filter(isExistingFileInfo)
|
|
||||||
.map((file): FavoriteEntry => ({ type: "file", name: file.name, details: file })),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const removeFavorite = async ({ type, details }: FavoriteEntry) => {
|
|
||||||
if (type === "directory") {
|
|
||||||
await setDirectoryFavorite(details, false);
|
|
||||||
} else {
|
|
||||||
await setFileFavorite(details, false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
/* eslint-disable svelte/prefer-svelte-reactivity -- Lookup tables of reactive entries */
|
|
||||||
import { untrack } from "svelte";
|
|
||||||
|
|
||||||
interface FilesystemCacheOptions<K, V> {
|
|
||||||
fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
|
|
||||||
fetchFromServer: (key: K, cachedValue: V | undefined) => Promise<V>;
|
|
||||||
bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
|
|
||||||
bulkFetchFromServer?: (keys: Map<K, { cachedValue: V | undefined }>) => Promise<Map<K, V>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Entry<V> {
|
|
||||||
value?: V;
|
|
||||||
promise?: Promise<V>;
|
|
||||||
isInvalidated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Caches filesystem entries as reactive objects which are updated in place, so that every view
|
|
||||||
// of an entry reflects its latest state. Values are first loaded from IndexedDB (if any) and then
|
|
||||||
// revalidated against the server.
|
|
||||||
export class FilesystemCache<K, V extends object> {
|
|
||||||
private map = new Map<K, Entry<V>>();
|
|
||||||
|
|
||||||
constructor(private readonly options: FilesystemCacheOptions<K, V>) {}
|
|
||||||
|
|
||||||
private getOrCreateEntry(key: K) {
|
|
||||||
let entry = this.map.get(key);
|
|
||||||
if (!entry) {
|
|
||||||
const newEntry: Entry<V> = $state({ isInvalidated: false });
|
|
||||||
entry = newEntry;
|
|
||||||
this.map.set(key, entry);
|
|
||||||
}
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
private settle(entry: Entry<V>, value: V) {
|
|
||||||
if (entry.value) {
|
|
||||||
Object.assign(entry.value, value);
|
|
||||||
} else {
|
|
||||||
entry.value = value;
|
|
||||||
}
|
|
||||||
return entry.value!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private finish(key: K, entry: Entry<V>) {
|
|
||||||
entry.promise = undefined;
|
|
||||||
// Mutations may invalidate the entry while it is being revalidated, in which case it is queried
|
|
||||||
// again unless it was evicted or replaced in the meantime
|
|
||||||
if (entry.isInvalidated && this.map.get(key) === entry) {
|
|
||||||
void Promise.resolve(this.get(key)).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the cached value synchronously if available, and revalidates it in the background
|
|
||||||
get(key: K, options?: { fetchFromServer?: (cachedValue: V | undefined) => Promise<V> }) {
|
|
||||||
return untrack(() => {
|
|
||||||
const entry = this.getOrCreateEntry(key);
|
|
||||||
if (entry.promise) return entry.value ?? entry.promise;
|
|
||||||
|
|
||||||
const { promise, resolve, reject } = Promise.withResolvers<V>();
|
|
||||||
promise.catch(() => {}); // Rejections are handled by callers waiting for the promise
|
|
||||||
entry.promise = promise;
|
|
||||||
entry.isInvalidated = false;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
let cachedValue = entry.value && ($state.snapshot(entry.value) as V);
|
|
||||||
if (!cachedValue) {
|
|
||||||
cachedValue = await this.options.fetchFromIndexedDB(key);
|
|
||||||
if (cachedValue) {
|
|
||||||
resolve(this.settle(entry, cachedValue));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadedValue = await (options?.fetchFromServer?.(cachedValue) ??
|
|
||||||
this.options.fetchFromServer(key, cachedValue));
|
|
||||||
resolve(this.settle(entry, loadedValue));
|
|
||||||
})()
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
reject(e);
|
|
||||||
})
|
|
||||||
.finally(() => this.finish(key, entry));
|
|
||||||
|
|
||||||
return entry.value ?? promise;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bulkGet(keys: Set<K>) {
|
|
||||||
return untrack(() => {
|
|
||||||
const entries = new Map<K, Entry<V>>();
|
|
||||||
const pending = new Map<K, PromiseWithResolvers<V>>();
|
|
||||||
for (const key of keys) {
|
|
||||||
const entry = this.getOrCreateEntry(key);
|
|
||||||
entries.set(key, entry);
|
|
||||||
if (entry.promise) continue;
|
|
||||||
|
|
||||||
const resolvers = Promise.withResolvers<V>();
|
|
||||||
resolvers.promise.catch(() => {});
|
|
||||||
entry.promise = resolvers.promise;
|
|
||||||
entry.isInvalidated = false;
|
|
||||||
pending.set(key, resolvers);
|
|
||||||
}
|
|
||||||
|
|
||||||
const settleAll = (values: Map<K, V>) => {
|
|
||||||
values.forEach((value, key) => {
|
|
||||||
pending.get(key)?.resolve(this.settle(entries.get(key)!, value));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const pendingKeys = [...pending.keys()];
|
|
||||||
settleAll(
|
|
||||||
await this.options.bulkFetchFromIndexedDB!(
|
|
||||||
new Set(pendingKeys.filter((key) => !entries.get(key)!.value)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
settleAll(
|
|
||||||
await this.options.bulkFetchFromServer!(
|
|
||||||
new Map(pendingKeys.map((key) => [key, { cachedValue: entries.get(key)!.value }])),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
pending.forEach(({ reject }) => reject(e));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
pending.forEach((_, key) => this.finish(key, entries.get(key)!));
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeResult = () => new Map([...entries].map(([key, entry]) => [key, entry.value!]));
|
|
||||||
const waiting = [...entries.values()]
|
|
||||||
.filter((entry) => !entry.value)
|
|
||||||
.map((entry) => entry.promise!);
|
|
||||||
return waiting.length > 0 ? Promise.all(waiting).then(makeResult) : makeResult();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revalidates the entry if it has been loaded
|
|
||||||
invalidate(key: K) {
|
|
||||||
const entry = this.map.get(key);
|
|
||||||
if (entry) {
|
|
||||||
entry.isInvalidated = true;
|
|
||||||
void Promise.resolve(this.get(key)).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
evict(key: K) {
|
|
||||||
this.map.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
|
|
||||||
import { isTRPCClientError, trpc } from "#trpc/client.ts";
|
|
||||||
import { FilesystemCache } from "./cache.svelte.ts";
|
|
||||||
import { invalidateFile } from "./file.ts";
|
|
||||||
import { toCategoryInfo, toFileInfo } from "./dto.ts";
|
|
||||||
import { encryptName, generateEntryKeys } from "./metadata.ts";
|
|
||||||
import { useResource } from "./resource.svelte.ts";
|
|
||||||
import type { CategoryInfo, DataKey, LocalCategoryInfo, MaybeCategoryInfo } from "./types.ts";
|
|
||||||
|
|
||||||
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
|
|
||||||
async fetchFromIndexedDB(id) {
|
|
||||||
const [category, subCategories] = await Promise.all([
|
|
||||||
id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined,
|
|
||||||
IndexedDB.getCategoryInfos(id),
|
|
||||||
]);
|
|
||||||
const files = category?.files
|
|
||||||
? await Promise.all(
|
|
||||||
category.files.map(async (file) => {
|
|
||||||
const fileInfo = await IndexedDB.getFileInfo(file.id);
|
|
||||||
return fileInfo
|
|
||||||
? {
|
|
||||||
id: file.id,
|
|
||||||
parentId: fileInfo.parentId,
|
|
||||||
contentType: fileInfo.contentType,
|
|
||||||
name: fileInfo.name,
|
|
||||||
createdAt: fileInfo.createdAt,
|
|
||||||
lastModifiedAt: fileInfo.lastModifiedAt,
|
|
||||||
isFavorite: fileInfo.isFavorite,
|
|
||||||
isRecursive: file.isRecursive,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (id === "root") {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
exists: true,
|
|
||||||
subCategories,
|
|
||||||
};
|
|
||||||
} else if (category) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
exists: true,
|
|
||||||
parentId: category.parentId,
|
|
||||||
name: category.name,
|
|
||||||
subCategories,
|
|
||||||
files: files?.filter((file) => !!file) ?? [],
|
|
||||||
isFileRecursive: category.isFileRecursive ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchFromServer(id, cachedInfo) {
|
|
||||||
try {
|
|
||||||
const category = await trpc.category.get.query({ id, recurse: true });
|
|
||||||
const [subCategories, files, metadata] = await Promise.all([
|
|
||||||
Promise.all(category.subCategories.map(toCategoryInfo)),
|
|
||||||
Promise.all(
|
|
||||||
(category.files ?? []).map(async (file) => ({
|
|
||||||
...(await toFileInfo(file)),
|
|
||||||
isRecursive: file.isRecursive,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
category.metadata && toCategoryInfo(category.metadata),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return storeToIndexedDB(
|
|
||||||
metadata
|
|
||||||
? {
|
|
||||||
...metadata,
|
|
||||||
subCategories,
|
|
||||||
files,
|
|
||||||
isFileRecursive: cachedInfo?.isFileRecursive ?? false,
|
|
||||||
}
|
|
||||||
: { id: "root", subCategories },
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
|
||||||
await IndexedDB.deleteCategoryInfo(id as number);
|
|
||||||
return { id, exists: false };
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const storeToIndexedDB = (info: CategoryInfo) => {
|
|
||||||
if (info.id !== "root") {
|
|
||||||
void IndexedDB.storeCategoryInfo(info);
|
|
||||||
|
|
||||||
// TODO: Bulk Upsert
|
|
||||||
new Map(info.files.map((file) => [file.id, file])).forEach((file) => {
|
|
||||||
void IndexedDB.storeFileInfo(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Bulk Upsert
|
|
||||||
info.subCategories.forEach((category) => {
|
|
||||||
void IndexedDB.storeCategoryInfo(category);
|
|
||||||
});
|
|
||||||
|
|
||||||
void IndexedDB.deleteDanglingCategoryInfos(
|
|
||||||
info.id,
|
|
||||||
new Set(info.subCategories.map(({ id }) => id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ...info, exists: true as const };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCategoryInfo = (id: CategoryId) => {
|
|
||||||
return cache.get(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCategory = (getId: () => CategoryId) => {
|
|
||||||
return useResource(getId, (id) => getCategoryInfo(id));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const invalidateCategory = (id: CategoryId) => {
|
|
||||||
cache.invalidate(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createCategory = async (parentId: CategoryId, name: string) => {
|
|
||||||
await trpc.category.create.mutate({ parent: parentId, ...(await generateEntryKeys(name)) });
|
|
||||||
invalidateCategory(parentId);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const renameCategory = async (
|
|
||||||
category: { id: number; parentId: CategoryId; dataKey?: DataKey },
|
|
||||||
newName: string,
|
|
||||||
) => {
|
|
||||||
if (!category.dataKey) {
|
|
||||||
throw new Error("Data key not loaded");
|
|
||||||
}
|
|
||||||
|
|
||||||
await trpc.category.rename.mutate({
|
|
||||||
id: category.id,
|
|
||||||
dekVersion: category.dataKey.version,
|
|
||||||
...(await encryptName(newName, category.dataKey.key)),
|
|
||||||
});
|
|
||||||
invalidateCategory(category.parentId);
|
|
||||||
invalidateCategory(category.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteCategory = async (category: { id: number; parentId: CategoryId }) => {
|
|
||||||
await trpc.category.delete.mutate({ id: category.id });
|
|
||||||
cache.evict(category.id);
|
|
||||||
invalidateCategory(category.parentId);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const addFileToCategory = async (fileId: number, categoryId: number) => {
|
|
||||||
await trpc.category.addFile.mutate({ id: categoryId, file: fileId });
|
|
||||||
invalidateFile(fileId);
|
|
||||||
invalidateCategory(categoryId);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {
|
|
||||||
await trpc.category.removeFile.mutate({ id: categoryId, file: fileId });
|
|
||||||
invalidateFile(fileId);
|
|
||||||
invalidateCategory(categoryId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Whether to show the files of subcategories is a local preference
|
|
||||||
export const setCategoryFileRecursive = async (category: LocalCategoryInfo, value: boolean) => {
|
|
||||||
category.isFileRecursive = value;
|
|
||||||
await IndexedDB.updateCategoryInfo(category.id, { isFileRecursive: value });
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<script module lang="ts">
|
|
||||||
import type { DataKey, SubCategoryInfo } from "#lib/features/filesystem/index.ts";
|
|
||||||
|
|
||||||
export interface SelectedCategory {
|
|
||||||
id: number;
|
|
||||||
dataKey?: DataKey;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import type { Component } from "svelte";
|
|
||||||
import type { SvelteHTMLElements } from "svelte/elements";
|
|
||||||
import { ActionEntryButton, CategoryLabel } from "#lib/ui/index.ts";
|
|
||||||
import { sortEntries } from "#lib/utils/index.ts";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
categories: SubCategoryInfo[];
|
|
||||||
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
|
|
||||||
onCategoryClick: (category: SelectedCategory) => void;
|
|
||||||
onCategoryMenuClick?: (category: SelectedCategory) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { categories, categoryMenuIcon, onCategoryClick, onCategoryMenuClick }: Props = $props();
|
|
||||||
|
|
||||||
let categoriesWithName = $derived(sortEntries([...categories]));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if categoriesWithName.length > 0}
|
|
||||||
<div class="space-y-1">
|
|
||||||
{#each categoriesWithName as category (category.id)}
|
|
||||||
<ActionEntryButton
|
|
||||||
class="h-12"
|
|
||||||
onclick={() => onCategoryClick(category)}
|
|
||||||
actionButtonIcon={categoryMenuIcon}
|
|
||||||
onActionButtonClick={() => onCategoryMenuClick?.(category)}
|
|
||||||
>
|
|
||||||
<CategoryLabel name={category.name} />
|
|
||||||
</ActionEntryButton>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { TextInputModal } from "#lib/ui/index.ts";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean;
|
|
||||||
onCreateClick: (name: string) => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { isOpen = $bindable(), onCreateClick }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TextInputModal
|
|
||||||
bind:isOpen
|
|
||||||
title="새 카테고리"
|
|
||||||
placeholder="카테고리 이름"
|
|
||||||
submitText="만들기"
|
|
||||||
onSubmitClick={onCreateClick}
|
|
||||||
/>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Component } from "svelte";
|
|
||||||
import type { SvelteHTMLElements } from "svelte/elements";
|
|
||||||
import { ActionEntryButton, DirectoryEntryLabel } from "#lib/ui/index.ts";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
action?: {
|
|
||||||
icon: Component<SvelteHTMLElements["svg"]>;
|
|
||||||
onclick: () => void;
|
|
||||||
};
|
|
||||||
info: { name: string; isFavorite?: boolean };
|
|
||||||
onclick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { action, info, onclick }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ActionEntryButton
|
|
||||||
class="h-14"
|
|
||||||
{onclick}
|
|
||||||
actionButtonIcon={action?.icon}
|
|
||||||
onActionButtonClick={action?.onclick}
|
|
||||||
>
|
|
||||||
<DirectoryEntryLabel type="directory" name={info.name} isFavorite={info.isFavorite} />
|
|
||||||
</ActionEntryButton>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Component } from "svelte";
|
|
||||||
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
|
||||||
import { getFileThumbnail } from "#lib/client/cache/index.ts";
|
|
||||||
import { ActionEntryButton, DirectoryEntryLabel } from "#lib/ui/index.ts";
|
|
||||||
import { formatDateTime } from "#lib/utils/index.ts";
|
|
||||||
import type { SummarizedFileInfo } from "../types.ts";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
action?: {
|
|
||||||
icon: Component<SvelteHTMLElements["svg"]>;
|
|
||||||
onclick: () => void;
|
|
||||||
class?: ClassValue;
|
|
||||||
};
|
|
||||||
info: SummarizedFileInfo;
|
|
||||||
onclick?: () => void;
|
|
||||||
showThumbnail?: boolean;
|
|
||||||
subtext?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { action, info, onclick, showThumbnail = true, subtext }: Props = $props();
|
|
||||||
|
|
||||||
let thumbnail = $derived(showThumbnail ? getFileThumbnail(info) : undefined);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ActionEntryButton
|
|
||||||
class="h-14"
|
|
||||||
{onclick}
|
|
||||||
actionButtonIcon={action?.icon}
|
|
||||||
actionButtonClass={action?.class}
|
|
||||||
onActionButtonClick={action?.onclick}
|
|
||||||
>
|
|
||||||
<DirectoryEntryLabel
|
|
||||||
type="file"
|
|
||||||
thumbnail={thumbnail?.url}
|
|
||||||
name={info.name}
|
|
||||||
subtext={subtext ?? formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
|
||||||
isFavorite={info.isFavorite}
|
|
||||||
/>
|
|
||||||
</ActionEntryButton>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getFileThumbnail } from "#lib/client/cache/index.ts";
|
|
||||||
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.ts";
|
|
||||||
import { afterPress } from "#lib/ui/index.ts";
|
|
||||||
|
|
||||||
import IconFavorite from "~icons/material-symbols/favorite";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
info: SummarizedFileInfo;
|
|
||||||
onclick?: (file: SummarizedFileInfo) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { info, onclick }: Props = $props();
|
|
||||||
|
|
||||||
let thumbnail = $derived(getFileThumbnail(info));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={afterPress(onclick && (() => onclick(info)))}
|
|
||||||
class="relative aspect-square overflow-hidden rounded-sm transition active:scale-95 active:brightness-90"
|
|
||||||
>
|
|
||||||
{#if thumbnail?.url}
|
|
||||||
<img src={thumbnail?.url} alt={info.name} class="size-full object-cover" />
|
|
||||||
{:else}
|
|
||||||
<div class="size-full bg-gray-100"></div>
|
|
||||||
{/if}
|
|
||||||
{#if info.isFavorite}
|
|
||||||
<div class={["absolute right-0.5 bottom-0.5"]}>
|
|
||||||
<IconFavorite
|
|
||||||
class="text-sm text-red-500"
|
|
||||||
style="filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px white)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Component } from "svelte";
|
|
||||||
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
|
||||||
import { Categories, type SelectedCategory } from "#lib/features/filesystem/components/index.ts";
|
|
||||||
import type { CategoryInfo } from "#lib/features/filesystem/index.ts";
|
|
||||||
import { IconEntryButton } from "#lib/ui/index.ts";
|
|
||||||
|
|
||||||
import IconAddCircle from "~icons/material-symbols/add-circle";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
class?: ClassValue;
|
|
||||||
info: CategoryInfo;
|
|
||||||
onSubCategoryClick: (subCategory: SelectedCategory) => void;
|
|
||||||
onSubCategoryCreateClick?: () => void;
|
|
||||||
onSubCategoryMenuClick?: (category: SelectedCategory) => void;
|
|
||||||
subCategoryCreatePosition?: "top" | "bottom" | "none";
|
|
||||||
subCategoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
class: className,
|
|
||||||
info,
|
|
||||||
onSubCategoryClick,
|
|
||||||
onSubCategoryCreateClick,
|
|
||||||
onSubCategoryMenuClick,
|
|
||||||
subCategoryCreatePosition = "none",
|
|
||||||
subCategoryMenuIcon,
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class={["space-y-1", className]}>
|
|
||||||
{#snippet subCategoryCreate()}
|
|
||||||
<IconEntryButton
|
|
||||||
icon={IconAddCircle}
|
|
||||||
onclick={onSubCategoryCreateClick}
|
|
||||||
class="h-12 w-full"
|
|
||||||
iconClass="text-gray-600"
|
|
||||||
textClass="text-gray-700"
|
|
||||||
>
|
|
||||||
카테고리 추가하기
|
|
||||||
</IconEntryButton>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#if subCategoryCreatePosition === "top"}
|
|
||||||
{@render subCategoryCreate()}
|
|
||||||
{/if}
|
|
||||||
<Categories
|
|
||||||
categories={info.subCategories}
|
|
||||||
categoryMenuIcon={subCategoryMenuIcon}
|
|
||||||
onCategoryClick={onSubCategoryClick}
|
|
||||||
onCategoryMenuClick={onSubCategoryMenuClick}
|
|
||||||
/>
|
|
||||||
{#if subCategoryCreatePosition === "bottom"}
|
|
||||||
{@render subCategoryCreate()}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export * from "./Categories.svelte";
|
|
||||||
export { default as Categories } from "./Categories.svelte";
|
|
||||||
export { default as CategoryCreateModal } from "./CategoryCreateModal.svelte";
|
|
||||||
export { default as DirectoryEntry } from "./DirectoryEntry.svelte";
|
|
||||||
export { default as FileEntry } from "./FileEntry.svelte";
|
|
||||||
export { default as FileThumbnailButton } from "./FileThumbnailButton.svelte";
|
|
||||||
export { default as SubCategories } from "./SubCategories.svelte";
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import {
|
|
||||||
deleteFileCache,
|
|
||||||
deleteFileThumbnailCache,
|
|
||||||
storeFileThumbnailCache,
|
|
||||||
} from "#lib/client/cache/index.ts";
|
|
||||||
import {
|
|
||||||
getDecryptedFileUrl,
|
|
||||||
prepareFileDecryption,
|
|
||||||
saveDecryptedFile,
|
|
||||||
} from "#lib/client/service-worker.ts";
|
|
||||||
import { getAllFileInfos } from "#lib/client/storage/indexed-db/index.ts";
|
|
||||||
import { uploadBlob } from "#lib/client/transfer/chunked-upload.ts";
|
|
||||||
import { trpc } from "#trpc/client.ts";
|
|
||||||
import type { DataKey } from "./types.ts";
|
|
||||||
|
|
||||||
interface StreamableFile {
|
|
||||||
id: number;
|
|
||||||
isLegacy?: boolean;
|
|
||||||
dataKey?: DataKey;
|
|
||||||
contentType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns a URL from which the service worker streams the decrypted content of the file
|
|
||||||
export const getFileStreamUrl = async ({ id, isLegacy, dataKey, contentType }: StreamableFile) => {
|
|
||||||
if (!dataKey) {
|
|
||||||
throw new Error("Data key not loaded");
|
|
||||||
}
|
|
||||||
|
|
||||||
await prepareFileDecryption(id, { isLegacy: !!isLegacy, dataKey: dataKey.key, contentType });
|
|
||||||
return getDecryptedFileUrl(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFileBlob = async (file: StreamableFile) => {
|
|
||||||
const res = await fetch(await getFileStreamUrl(file));
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Failed to download the file");
|
|
||||||
}
|
|
||||||
return await res.blob();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveFile = async (file: StreamableFile & { name: string }) => {
|
|
||||||
await getFileStreamUrl(file);
|
|
||||||
saveDecryptedFile(file.id, file.name);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uploadFileThumbnail = async (fileId: number, thumbnail: Blob, dataKey: DataKey) => {
|
|
||||||
const { uploadId } = await trpc.upload.startFileThumbnailUpload.mutate({
|
|
||||||
file: fileId,
|
|
||||||
dekVersion: dataKey.version,
|
|
||||||
});
|
|
||||||
await uploadBlob(uploadId, thumbnail, dataKey.key);
|
|
||||||
await trpc.upload.completeFileThumbnailUpload.mutate({ uploadId });
|
|
||||||
|
|
||||||
await storeFileThumbnailCache(fileId, await thumbnail.arrayBuffer());
|
|
||||||
};
|
|
||||||
|
|
||||||
// Deletes local caches of files deleted on other devices
|
|
||||||
export const cleanupDeletedFileCaches = async () => {
|
|
||||||
const liveFiles = new Set(await trpc.file.list.query());
|
|
||||||
const maybeCachedFiles = await getAllFileInfos();
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
maybeCachedFiles
|
|
||||||
.filter(({ id }) => !liveFiles.has(id))
|
|
||||||
.flatMap(({ id }) => [deleteFileCache(id), deleteFileThumbnailCache(id)]),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { deleteFileCache, deleteFileThumbnailCache } from "#lib/client/cache/index.ts";
|
|
||||||
import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
|
|
||||||
import { isTRPCClientError, trpc } from "#trpc/client.ts";
|
|
||||||
import { FilesystemCache } from "./cache.svelte.ts";
|
|
||||||
import { toDirectoryInfo, toFileInfo, type DirectoryDto } from "./dto.ts";
|
|
||||||
import { encryptName, generateEntryKeys } from "./metadata.ts";
|
|
||||||
import { useResource } from "./resource.svelte.ts";
|
|
||||||
import type { DataKey, DirectoryInfo, MaybeDirectoryInfo } from "./types.ts";
|
|
||||||
|
|
||||||
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
|
|
||||||
async fetchFromIndexedDB(id) {
|
|
||||||
const [directory, subDirectories, files] = await Promise.all([
|
|
||||||
id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined,
|
|
||||||
IndexedDB.getDirectoryInfos(id),
|
|
||||||
IndexedDB.getFileInfos(id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (id === "root") {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
exists: true,
|
|
||||||
subDirectories,
|
|
||||||
files,
|
|
||||||
};
|
|
||||||
} else if (directory) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
exists: true,
|
|
||||||
parentId: directory.parentId,
|
|
||||||
name: directory.name,
|
|
||||||
subDirectories,
|
|
||||||
files,
|
|
||||||
isFavorite: directory.isFavorite,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchFromServer(id) {
|
|
||||||
try {
|
|
||||||
const directory = await trpc.directory.get.query({ id });
|
|
||||||
const [subDirectories, files, metadata] = await Promise.all([
|
|
||||||
Promise.all(directory.subDirectories.map(toDirectoryInfo)),
|
|
||||||
Promise.all(directory.files.map(toFileInfo)),
|
|
||||||
directory.metadata && toDirectoryInfo(directory.metadata),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return storeToIndexedDB(
|
|
||||||
metadata ? { ...metadata, subDirectories, files } : { id: "root", subDirectories, files },
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
|
||||||
await IndexedDB.deleteDirectoryInfo(id as number);
|
|
||||||
return { id, exists: false as const };
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const storeToIndexedDB = (info: DirectoryInfo) => {
|
|
||||||
if (info.id !== "root") {
|
|
||||||
void IndexedDB.storeDirectoryInfo(info);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Bulk Upsert
|
|
||||||
info.subDirectories.forEach((subDirectory) => {
|
|
||||||
void IndexedDB.storeDirectoryInfo(subDirectory);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Bulk Upsert
|
|
||||||
info.files.forEach((file) => {
|
|
||||||
void IndexedDB.storeFileInfo(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
void IndexedDB.deleteDanglingDirectoryInfos(
|
|
||||||
info.id,
|
|
||||||
new Set(info.subDirectories.map(({ id }) => id)),
|
|
||||||
);
|
|
||||||
void IndexedDB.deleteDanglingFileInfos(info.id, new Set(info.files.map(({ id }) => id)));
|
|
||||||
|
|
||||||
return { ...info, exists: true as const };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Uses `serverResponse` instead of fetching the directory from the server if given
|
|
||||||
export const getDirectoryInfo = (id: DirectoryId, options?: { serverResponse?: DirectoryDto }) => {
|
|
||||||
const serverResponse = options?.serverResponse;
|
|
||||||
return cache.get(id, {
|
|
||||||
fetchFromServer:
|
|
||||||
serverResponse &&
|
|
||||||
(async (cachedValue) =>
|
|
||||||
storeToIndexedDB({
|
|
||||||
subDirectories: [],
|
|
||||||
files: [],
|
|
||||||
...cachedValue,
|
|
||||||
...(await toDirectoryInfo(serverResponse)),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDirectory = (getId: () => DirectoryId) => {
|
|
||||||
return useResource(getId, (id) => getDirectoryInfo(id));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const invalidateDirectory = (id: DirectoryId) => {
|
|
||||||
cache.invalidate(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createDirectory = async (parentId: DirectoryId, name: string) => {
|
|
||||||
await trpc.directory.create.mutate({ parent: parentId, ...(await generateEntryKeys(name)) });
|
|
||||||
invalidateDirectory(parentId);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const renameDirectory = async (
|
|
||||||
directory: { id: number; parentId: DirectoryId; dataKey?: DataKey },
|
|
||||||
newName: string,
|
|
||||||
) => {
|
|
||||||
if (!directory.dataKey) {
|
|
||||||
throw new Error("Data key not loaded");
|
|
||||||
}
|
|
||||||
|
|
||||||
await trpc.directory.rename.mutate({
|
|
||||||
id: directory.id,
|
|
||||||
dekVersion: directory.dataKey.version,
|
|
||||||
...(await encryptName(newName, directory.dataKey.key)),
|
|
||||||
});
|
|
||||||
invalidateDirectory(directory.parentId);
|
|
||||||
invalidateDirectory(directory.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteDirectory = async (directory: { id: number; parentId: DirectoryId }) => {
|
|
||||||
const { deletedFiles } = await trpc.directory.delete.mutate({ id: directory.id });
|
|
||||||
cache.evict(directory.id);
|
|
||||||
invalidateDirectory(directory.parentId);
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
deletedFiles.flatMap((fileId) => [deleteFileCache(fileId), deleteFileThumbnailCache(fileId)]),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setDirectoryFavorite = async (
|
|
||||||
directory: { id: number; parentId: DirectoryId },
|
|
||||||
isFavorite: boolean,
|
|
||||||
) => {
|
|
||||||
if (isFavorite) {
|
|
||||||
await trpc.favorites.addDirectory.mutate({ id: directory.id });
|
|
||||||
} else {
|
|
||||||
await trpc.favorites.removeDirectory.mutate({ id: directory.id });
|
|
||||||
}
|
|
||||||
invalidateDirectory(directory.parentId);
|
|
||||||
invalidateDirectory(directory.id);
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type { RouterOutputs } from "#trpc/router.server.ts";
|
|
||||||
import {
|
|
||||||
decryptCategoryMetadata,
|
|
||||||
decryptDirectoryMetadata,
|
|
||||||
decryptFileMetadata,
|
|
||||||
} from "./metadata.ts";
|
|
||||||
import type { FileInfo, SubCategoryInfo, SubDirectoryInfo, SummarizedFileInfo } from "./types.ts";
|
|
||||||
|
|
||||||
// Shapes of filesystem entries returned by the server
|
|
||||||
export type DirectoryDto = RouterOutputs["directory"]["get"]["subDirectories"][number];
|
|
||||||
export type FileDto = RouterOutputs["directory"]["get"]["files"][number];
|
|
||||||
export type CategoryDto = RouterOutputs["category"]["get"]["subCategories"][number];
|
|
||||||
export type FileWithCategoriesDto = RouterOutputs["file"]["get"];
|
|
||||||
|
|
||||||
export const toDirectoryInfo = async (directory: DirectoryDto): Promise<SubDirectoryInfo> => ({
|
|
||||||
id: directory.id,
|
|
||||||
parentId: directory.parent,
|
|
||||||
isFavorite: directory.isFavorite,
|
|
||||||
...(await decryptDirectoryMetadata(directory)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const toFileInfo = async (file: FileDto): Promise<SummarizedFileInfo> => ({
|
|
||||||
id: file.id,
|
|
||||||
isLegacy: file.isLegacy,
|
|
||||||
parentId: file.parent,
|
|
||||||
contentType: file.contentType,
|
|
||||||
isFavorite: file.isFavorite,
|
|
||||||
...(await decryptFileMetadata(file)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const toCategoryInfo = async (category: CategoryDto): Promise<SubCategoryInfo> => ({
|
|
||||||
id: category.id,
|
|
||||||
parentId: category.parent,
|
|
||||||
...(await decryptCategoryMetadata(category)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const toFileWithCategoriesInfo = async ({
|
|
||||||
categories,
|
|
||||||
...file
|
|
||||||
}: FileWithCategoriesDto): Promise<FileInfo> => {
|
|
||||||
const [fileInfo, categoryInfos] = await Promise.all([
|
|
||||||
toFileInfo(file),
|
|
||||||
Promise.all(categories.map(toCategoryInfo)),
|
|
||||||
]);
|
|
||||||
return { ...fileInfo, categories: categoryInfos };
|
|
||||||
};
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import { deleteFileCache, deleteFileThumbnailCache } from "#lib/client/cache/index.ts";
|
|
||||||
import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
|
|
||||||
import { isTRPCClientError, trpc } from "#trpc/client.ts";
|
|
||||||
import { FilesystemCache } from "./cache.svelte.ts";
|
|
||||||
import { invalidateDirectory } from "./directory.ts";
|
|
||||||
import { toFileInfo, toFileWithCategoriesInfo, type FileDto } from "./dto.ts";
|
|
||||||
import { encryptName } from "./metadata.ts";
|
|
||||||
import { useResource } from "./resource.svelte.ts";
|
|
||||||
import type { DataKey, FileInfo, MaybeFileInfo } from "./types.ts";
|
|
||||||
|
|
||||||
// Categories of files stored in IndexedDB, which may have been deleted
|
|
||||||
const getFileCategoryInfos = async (categoryIds: number[] | undefined) => {
|
|
||||||
const categories = await Promise.all(
|
|
||||||
(categoryIds ?? []).map((id) => IndexedDB.getCategoryInfo(id)),
|
|
||||||
);
|
|
||||||
return categories
|
|
||||||
.filter((category) => !!category)
|
|
||||||
.map(({ id, parentId, name }) => ({ id, parentId, name }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const cache = new FilesystemCache<number, MaybeFileInfo>({
|
|
||||||
async fetchFromIndexedDB(id) {
|
|
||||||
const file = await IndexedDB.getFileInfo(id);
|
|
||||||
if (file) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
exists: true,
|
|
||||||
parentId: file.parentId,
|
|
||||||
contentType: file.contentType,
|
|
||||||
name: file.name,
|
|
||||||
createdAt: file.createdAt,
|
|
||||||
lastModifiedAt: file.lastModifiedAt,
|
|
||||||
categories: await getFileCategoryInfos(file.categoryIds),
|
|
||||||
isFavorite: file.isFavorite,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchFromServer(id) {
|
|
||||||
try {
|
|
||||||
return storeToIndexedDB(await toFileWithCategoriesInfo(await trpc.file.get.query({ id })));
|
|
||||||
} catch (e) {
|
|
||||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
|
||||||
await IndexedDB.deleteFileInfo(id);
|
|
||||||
return { id, exists: false as const };
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async bulkFetchFromIndexedDB(ids) {
|
|
||||||
const files = (await IndexedDB.bulkGetFileInfos([...ids])).filter((file) => !!file);
|
|
||||||
return new Map(
|
|
||||||
await Promise.all(
|
|
||||||
files.map(
|
|
||||||
async (file) =>
|
|
||||||
[
|
|
||||||
file.id,
|
|
||||||
{
|
|
||||||
...file,
|
|
||||||
exists: true as const,
|
|
||||||
categories: await getFileCategoryInfos(file.categoryIds),
|
|
||||||
},
|
|
||||||
] as const,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async bulkFetchFromServer(ids) {
|
|
||||||
const idsArray = [...ids.keys()];
|
|
||||||
|
|
||||||
const filesRaw = await trpc.file.bulkGet.query({ ids: idsArray });
|
|
||||||
const files = await Promise.all(filesRaw.map(toFileWithCategoriesInfo));
|
|
||||||
|
|
||||||
const existingIds = new Set(filesRaw.map(({ id }) => id));
|
|
||||||
const deletedIds = idsArray.filter((id) => !existingIds.has(id));
|
|
||||||
|
|
||||||
void IndexedDB.bulkDeleteFileInfos(deletedIds);
|
|
||||||
return new Map<number, MaybeFileInfo>([
|
|
||||||
...bulkStoreToIndexedDB(files),
|
|
||||||
...deletedIds.map((id) => [id, { id, exists: false }] as const),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const storeToIndexedDB = (info: FileInfo) => {
|
|
||||||
void IndexedDB.storeFileInfo({
|
|
||||||
...info,
|
|
||||||
categoryIds: info.categories.map(({ id }) => id),
|
|
||||||
});
|
|
||||||
|
|
||||||
info.categories.forEach((category) => {
|
|
||||||
void IndexedDB.storeCategoryInfo(category);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ...info, exists: true as const };
|
|
||||||
};
|
|
||||||
|
|
||||||
const bulkStoreToIndexedDB = (infos: FileInfo[]) => {
|
|
||||||
// TODO: Bulk Upsert
|
|
||||||
infos.forEach((info) => {
|
|
||||||
void IndexedDB.storeFileInfo({
|
|
||||||
...info,
|
|
||||||
categoryIds: info.categories.map(({ id }) => id),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Bulk Upsert
|
|
||||||
new Map(
|
|
||||||
infos.flatMap(({ categories }) => categories).map((category) => [category.id, category]),
|
|
||||||
).forEach((category) => {
|
|
||||||
void IndexedDB.storeCategoryInfo(category);
|
|
||||||
});
|
|
||||||
|
|
||||||
return infos.map((info) => [info.id, { ...info, exists: true }] as const);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Uses `serverResponse` instead of fetching the file from the server if given
|
|
||||||
export const getFileInfo = (id: number, options?: { serverResponse?: FileDto }) => {
|
|
||||||
const serverResponse = options?.serverResponse;
|
|
||||||
return cache.get(id, {
|
|
||||||
fetchFromServer:
|
|
||||||
serverResponse &&
|
|
||||||
(async (cachedValue) =>
|
|
||||||
storeToIndexedDB({
|
|
||||||
categories: [],
|
|
||||||
...cachedValue,
|
|
||||||
...(await toFileInfo(serverResponse)),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bulkGetFileInfo = (ids: number[]) => {
|
|
||||||
return cache.bulkGet(new Set(ids));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useFile = (getId: () => number) => {
|
|
||||||
return useResource(getId, (id) => getFileInfo(id));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const invalidateFile = (id: number) => {
|
|
||||||
cache.invalidate(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const renameFile = async (
|
|
||||||
file: { id: number; parentId: DirectoryId; dataKey?: DataKey },
|
|
||||||
newName: string,
|
|
||||||
) => {
|
|
||||||
if (!file.dataKey) {
|
|
||||||
throw new Error("Data key not loaded");
|
|
||||||
}
|
|
||||||
|
|
||||||
await trpc.file.rename.mutate({
|
|
||||||
id: file.id,
|
|
||||||
dekVersion: file.dataKey.version,
|
|
||||||
...(await encryptName(newName, file.dataKey.key)),
|
|
||||||
});
|
|
||||||
invalidateDirectory(file.parentId);
|
|
||||||
invalidateFile(file.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteFile = async (file: { id: number; parentId: DirectoryId }) => {
|
|
||||||
await trpc.file.delete.mutate({ id: file.id });
|
|
||||||
cache.evict(file.id);
|
|
||||||
invalidateDirectory(file.parentId);
|
|
||||||
|
|
||||||
await Promise.all([deleteFileCache(file.id), deleteFileThumbnailCache(file.id)]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setFileFavorite = async (
|
|
||||||
file: { id: number; parentId: DirectoryId },
|
|
||||||
isFavorite: boolean,
|
|
||||||
) => {
|
|
||||||
if (isFavorite) {
|
|
||||||
await trpc.favorites.addFile.mutate({ id: file.id });
|
|
||||||
} else {
|
|
||||||
await trpc.favorites.removeFile.mutate({ id: file.id });
|
|
||||||
}
|
|
||||||
invalidateDirectory(file.parentId);
|
|
||||||
invalidateFile(file.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The most recently created media files among the files stored locally
|
|
||||||
export const getRecentMediaFileInfos = async (limit: number) => {
|
|
||||||
const files = await IndexedDB.getAllFileInfos();
|
|
||||||
return files
|
|
||||||
.filter(
|
|
||||||
({ contentType }) => contentType.startsWith("image/") || contentType.startsWith("video/"),
|
|
||||||
)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
(b.createdAt ?? b.lastModifiedAt).getTime() - (a.createdAt ?? a.lastModifiedAt).getTime(),
|
|
||||||
)
|
|
||||||
.slice(0, limit);
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export * from "./category.ts";
|
|
||||||
export * from "./content.ts";
|
|
||||||
export * from "./directory.ts";
|
|
||||||
export * from "./file.ts";
|
|
||||||
export * from "./types.ts";
|
|
||||||
export * from "./upload.ts";
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { keyring } from "#lib/client/keys/index.ts";
|
|
||||||
import {
|
|
||||||
decryptString,
|
|
||||||
encryptString,
|
|
||||||
generateDataKey,
|
|
||||||
unwrapDataKey,
|
|
||||||
wrapDataKey,
|
|
||||||
} from "#lib/shared/crypto/index.ts";
|
|
||||||
|
|
||||||
const unwrapEntryDataKey = async (metadata: { mekVersion: number; dek: string }) => {
|
|
||||||
const { dataKey } = await unwrapDataKey(
|
|
||||||
metadata.dek,
|
|
||||||
keyring.getMasterKey(metadata.mekVersion).key,
|
|
||||||
);
|
|
||||||
return dataKey;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const decryptDirectoryMetadata = async (metadata: {
|
|
||||||
mekVersion: number;
|
|
||||||
dek: string;
|
|
||||||
dekVersion: Date;
|
|
||||||
name: string;
|
|
||||||
nameIv: string;
|
|
||||||
}) => {
|
|
||||||
const dataKey = await unwrapEntryDataKey(metadata);
|
|
||||||
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
dataKey: { key: dataKey, version: metadata.dekVersion },
|
|
||||||
name,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
|
|
||||||
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const decryptFileMetadata = async (metadata: {
|
|
||||||
mekVersion: number;
|
|
||||||
dek: string;
|
|
||||||
dekVersion: Date;
|
|
||||||
name: string;
|
|
||||||
nameIv: string;
|
|
||||||
createdAt?: string;
|
|
||||||
createdAtIv?: string;
|
|
||||||
lastModifiedAt: string;
|
|
||||||
lastModifiedAtIv: string;
|
|
||||||
}) => {
|
|
||||||
const dataKey = await unwrapEntryDataKey(metadata);
|
|
||||||
const [name, createdAt, lastModifiedAt] = await Promise.all([
|
|
||||||
decryptString(metadata.name, metadata.nameIv, dataKey),
|
|
||||||
metadata.createdAt
|
|
||||||
? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey)
|
|
||||||
: undefined,
|
|
||||||
decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
dataKey: { key: dataKey, version: metadata.dekVersion },
|
|
||||||
name,
|
|
||||||
createdAt,
|
|
||||||
lastModifiedAt,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const decryptCategoryMetadata = decryptDirectoryMetadata;
|
|
||||||
|
|
||||||
export const encryptName = async (name: string, dataKey: CryptoKey) => {
|
|
||||||
const { ciphertext, iv } = await encryptString(name, dataKey);
|
|
||||||
return { name: ciphertext, nameIv: iv };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generates the keys of a new directory or category encrypted with the active master key
|
|
||||||
export const generateEntryKeys = async (name: string) => {
|
|
||||||
const masterKey = keyring.activeMasterKey;
|
|
||||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
|
||||||
return {
|
|
||||||
mekVersion: masterKey.version,
|
|
||||||
dek: await wrapDataKey(dataKey, masterKey.key),
|
|
||||||
dekVersion: dataKeyVersion,
|
|
||||||
...(await encryptName(name, dataKey)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { untrack } from "svelte";
|
|
||||||
|
|
||||||
// Exposes the value of a cache entry identified by a reactive key. Cached values are available
|
|
||||||
// synchronously, so that views don't flicker while revalidating.
|
|
||||||
export const useResource = <K, V>(getKey: () => K, load: (key: K) => V | Promise<V>) => {
|
|
||||||
let current: V | undefined = $state();
|
|
||||||
|
|
||||||
$effect.pre(() => {
|
|
||||||
const key = getKey();
|
|
||||||
let isStale = false;
|
|
||||||
|
|
||||||
const result = untrack(() => load(key));
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
result
|
|
||||||
.then((value) => {
|
|
||||||
if (!isStale) current = value;
|
|
||||||
})
|
|
||||||
.catch(() => {}); // Logged by the cache
|
|
||||||
} else {
|
|
||||||
current = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isStale = true;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
get current() {
|
|
||||||
return current;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
export type DataKey = { key: CryptoKey; version: Date };
|
|
||||||
|
|
||||||
export interface LocalDirectoryInfo {
|
|
||||||
id: number;
|
|
||||||
parentId: DirectoryId;
|
|
||||||
dataKey?: DataKey;
|
|
||||||
name: string;
|
|
||||||
subDirectories: SubDirectoryInfo[];
|
|
||||||
files: SummarizedFileInfo[];
|
|
||||||
isFavorite: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RootDirectoryInfo {
|
|
||||||
id: "root";
|
|
||||||
parentId?: undefined;
|
|
||||||
dataKey?: undefined;
|
|
||||||
name?: undefined;
|
|
||||||
subDirectories: SubDirectoryInfo[];
|
|
||||||
files: SummarizedFileInfo[];
|
|
||||||
isFavorite?: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
|
|
||||||
export type MaybeDirectoryInfo =
|
|
||||||
| (DirectoryInfo & { exists: true })
|
|
||||||
| ({ id: DirectoryId; exists: false } & AllUndefined<Omit<DirectoryInfo, "id">>);
|
|
||||||
|
|
||||||
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "subDirectories" | "files">;
|
|
||||||
|
|
||||||
export interface FileInfo {
|
|
||||||
id: number;
|
|
||||||
isLegacy?: boolean;
|
|
||||||
parentId: DirectoryId;
|
|
||||||
dataKey?: DataKey;
|
|
||||||
contentType: string;
|
|
||||||
name: string;
|
|
||||||
createdAt?: Date;
|
|
||||||
lastModifiedAt: Date;
|
|
||||||
categories: FileCategoryInfo[];
|
|
||||||
isFavorite: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MaybeFileInfo =
|
|
||||||
| (FileInfo & { exists: true })
|
|
||||||
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
|
|
||||||
|
|
||||||
export type SummarizedFileInfo = Omit<FileInfo, "categories">;
|
|
||||||
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
|
|
||||||
|
|
||||||
export interface LocalCategoryInfo {
|
|
||||||
id: number;
|
|
||||||
parentId: CategoryId;
|
|
||||||
dataKey?: DataKey;
|
|
||||||
name: string;
|
|
||||||
subCategories: SubCategoryInfo[];
|
|
||||||
files: CategoryFileInfo[];
|
|
||||||
isFileRecursive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RootCategoryInfo {
|
|
||||||
id: "root";
|
|
||||||
parentId?: undefined;
|
|
||||||
dataKey?: undefined;
|
|
||||||
name?: undefined;
|
|
||||||
subCategories: SubCategoryInfo[];
|
|
||||||
files?: undefined;
|
|
||||||
isFileRecursive?: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo;
|
|
||||||
export type MaybeCategoryInfo =
|
|
||||||
| (CategoryInfo & { exists: true })
|
|
||||||
| ({ id: CategoryId; exists: false } & AllUndefined<Omit<CategoryInfo, "id">>);
|
|
||||||
|
|
||||||
export type SubCategoryInfo = Omit<
|
|
||||||
LocalCategoryInfo,
|
|
||||||
"subCategories" | "files" | "isFileRecursive"
|
|
||||||
>;
|
|
||||||
export type FileCategoryInfo = Omit<SubCategoryInfo, "dataKey">;
|
|
||||||
|
|
||||||
export const isLocalDirectoryInfo = (
|
|
||||||
info: MaybeDirectoryInfo,
|
|
||||||
): info is LocalDirectoryInfo & { exists: true } => info.exists && info.id !== "root";
|
|
||||||
|
|
||||||
export const isExistingFileInfo = (info: MaybeFileInfo): info is FileInfo & { exists: true } =>
|
|
||||||
info.exists;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { storeFileThumbnailCache } from "#lib/client/cache/index.ts";
|
|
||||||
import { uploadFile as doUploadFile } from "#lib/client/transfer/index.ts";
|
|
||||||
import { invalidateDirectory } from "./directory.ts";
|
|
||||||
|
|
||||||
// Uploads the file and caches it with its thumbnail locally. Returns `false` if the upload is canceled.
|
|
||||||
export const uploadFile = async (
|
|
||||||
file: File,
|
|
||||||
parentId: DirectoryId,
|
|
||||||
onDuplicate: () => Promise<boolean>,
|
|
||||||
) => {
|
|
||||||
const res = await doUploadFile(file, parentId, onDuplicate);
|
|
||||||
if (!res) return false;
|
|
||||||
|
|
||||||
invalidateDirectory(parentId);
|
|
||||||
if (res.thumbnailBuffer) {
|
|
||||||
void storeFileThumbnailCache(res.fileId, res.thumbnailBuffer);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import {
|
|
||||||
getDirectoryInfo,
|
|
||||||
getFileInfo,
|
|
||||||
isExistingFileInfo,
|
|
||||||
isLocalDirectoryInfo,
|
|
||||||
type FileInfo,
|
|
||||||
type LocalCategoryInfo,
|
|
||||||
type LocalDirectoryInfo,
|
|
||||||
} from "#lib/features/filesystem/index.ts";
|
|
||||||
import { trpc } from "#trpc/client.ts";
|
|
||||||
|
|
||||||
export interface SearchFilter {
|
|
||||||
ancestorId: DirectoryId;
|
|
||||||
inFavorites: boolean;
|
|
||||||
categories: { info: LocalCategoryInfo; type: "include" | "exclude" }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
directories: LocalDirectoryInfo[];
|
|
||||||
files: FileInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCategoryIds = (filter: SearchFilter, type: "include" | "exclude") => {
|
|
||||||
return filter.categories.filter((category) => category.type === type).map(({ info }) => info.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const search = async (filter: SearchFilter) => {
|
|
||||||
const { directories: directoriesRaw, files: filesRaw } = await trpc.search.search.query({
|
|
||||||
ancestor: filter.ancestorId,
|
|
||||||
inFavorites: filter.inFavorites,
|
|
||||||
includeCategories: getCategoryIds(filter, "include"),
|
|
||||||
excludeCategories: getCategoryIds(filter, "exclude"),
|
|
||||||
});
|
|
||||||
const [directories, files] = await Promise.all([
|
|
||||||
Promise.all(
|
|
||||||
directoriesRaw.map((directory) =>
|
|
||||||
getDirectoryInfo(directory.id, { serverResponse: directory }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Promise.all(filesRaw.map((file) => getFileInfo(file.id, { serverResponse: file }))),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
directories: directories.filter(isLocalDirectoryInfo),
|
|
||||||
files: files.filter(isExistingFileInfo),
|
|
||||||
} satisfies SearchResult;
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { formatNetworkSpeed } from "#lib/utils/index.ts";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
progress?: number;
|
|
||||||
rate?: number; // Bytes per second
|
|
||||||
}
|
|
||||||
|
|
||||||
let { progress, rate }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
전송됨 {Math.floor((progress ?? 0) * 100)}% · {formatNetworkSpeed((rate ?? 0) * 8)}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { FileUploadState } from "#lib/client/transfer/index.ts";
|
|
||||||
import TransferProgress from "./TransferProgress.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
state: FileUploadState;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { state }: Props = $props();
|
|
||||||
|
|
||||||
const statusTexts = {
|
|
||||||
queued: "대기 중",
|
|
||||||
"encryption-pending": "준비 중",
|
|
||||||
encrypting: "암호화하는 중",
|
|
||||||
"upload-pending": "업로드를 기다리는 중",
|
|
||||||
uploaded: "업로드 완료",
|
|
||||||
canceled: "",
|
|
||||||
error: "업로드 실패",
|
|
||||||
} satisfies Record<Exclude<FileUploadState["status"], "uploading">, string>;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if state.status === "uploading"}
|
|
||||||
<TransferProgress progress={state.progress} rate={state.rate} />
|
|
||||||
{:else}
|
|
||||||
{statusTexts[state.status]}
|
|
||||||
{/if}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default as TransferProgress } from "./TransferProgress.svelte";
|
|
||||||
export { default as UploadStatusText } from "./UploadStatusText.svelte";
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const callGetApi = async (input: RequestInfo, fetchInternal = fetch) => {
|
||||||
|
return await fetchInternal(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const callPostApi = async <T>(input: RequestInfo, payload?: T, fetchInternal = fetch) => {
|
||||||
|
return await fetchInternal(input, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
File renamed without changes.
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./callApi";
|
||||||
|
export * from "./gotoStateful";
|
||||||
@@ -70,12 +70,12 @@ export const storeMasterKeys = async (keys: MasterKey[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getHmacSecrets = async () => {
|
export const getHmacSecrets = async () => {
|
||||||
return (await keyStore.hmacSecret.toArray()).filter(({ secret }) => secret.extractable);
|
return await keyStore.hmacSecret.toArray();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const storeHmacSecrets = async (secrets: HmacSecret[]) => {
|
export const storeHmacSecrets = async (secrets: HmacSecret[]) => {
|
||||||
if (secrets.some(({ secret }) => !secret.extractable)) {
|
if (secrets.some(({ secret }) => secret.extractable)) {
|
||||||
throw new Error("Hmac secrets must be extractable");
|
throw new Error("Hmac secrets must be nonextractable");
|
||||||
}
|
}
|
||||||
await keyStore.hmacSecret.bulkPut(secrets);
|
await keyStore.hmacSecret.bulkPut(secrets);
|
||||||
};
|
};
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
import {
|
import { encodeString, decodeString, encodeToBase64, decodeFromBase64 } from "./util";
|
||||||
encodeString,
|
|
||||||
decodeString,
|
|
||||||
encodeToBase64,
|
|
||||||
decodeFromBase64,
|
|
||||||
concatenateBuffers,
|
|
||||||
} from "./encoding.ts";
|
|
||||||
|
|
||||||
export const AES_GCM_IV_SIZE = 12;
|
|
||||||
export const AES_GCM_TAG_SIZE = 16;
|
|
||||||
|
|
||||||
export const generateMasterKey = async () => {
|
export const generateMasterKey = async () => {
|
||||||
return {
|
return {
|
||||||
masterKey: await crypto.subtle.generateKey(
|
masterKey: await window.crypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: "AES-KW",
|
name: "AES-KW",
|
||||||
length: 256,
|
length: 256,
|
||||||
@@ -24,7 +15,7 @@ export const generateMasterKey = async () => {
|
|||||||
|
|
||||||
export const generateDataKey = async () => {
|
export const generateDataKey = async () => {
|
||||||
return {
|
return {
|
||||||
dataKey: await crypto.subtle.generateKey(
|
dataKey: await window.crypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: "AES-GCM",
|
||||||
length: 256,
|
length: 256,
|
||||||
@@ -37,24 +28,22 @@ export const generateDataKey = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const makeAESKeyNonextractable = async (key: CryptoKey) => {
|
export const makeAESKeyNonextractable = async (key: CryptoKey) => {
|
||||||
return await crypto.subtle.importKey(
|
return await window.crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
await crypto.subtle.exportKey("raw", key),
|
await window.crypto.subtle.exportKey("raw", key),
|
||||||
key.algorithm,
|
key.algorithm,
|
||||||
false,
|
false,
|
||||||
key.usages,
|
key.usages,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapKey = async (key: CryptoKey, masterKey: CryptoKey) => {
|
export const wrapDataKey = async (dataKey: CryptoKey, masterKey: CryptoKey) => {
|
||||||
return encodeToBase64(await crypto.subtle.wrapKey("raw", key, masterKey, "AES-KW"));
|
return encodeToBase64(await window.crypto.subtle.wrapKey("raw", dataKey, masterKey, "AES-KW"));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const wrapDataKey = wrapKey;
|
|
||||||
|
|
||||||
export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => {
|
export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => {
|
||||||
return {
|
return {
|
||||||
dataKey: await crypto.subtle.unwrapKey(
|
dataKey: await window.crypto.subtle.unwrapKey(
|
||||||
"raw",
|
"raw",
|
||||||
decodeFromBase64(dataKeyWrapped),
|
decodeFromBase64(dataKeyWrapped),
|
||||||
masterKey,
|
masterKey,
|
||||||
@@ -66,11 +55,13 @@ export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const wrapHmacSecret = wrapKey;
|
export const wrapHmacSecret = async (hmacSecret: CryptoKey, masterKey: CryptoKey) => {
|
||||||
|
return encodeToBase64(await window.crypto.subtle.wrapKey("raw", hmacSecret, masterKey, "AES-KW"));
|
||||||
|
};
|
||||||
|
|
||||||
export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => {
|
export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => {
|
||||||
return {
|
return {
|
||||||
hmacSecret: await crypto.subtle.unwrapKey(
|
hmacSecret: await window.crypto.subtle.unwrapKey(
|
||||||
"raw",
|
"raw",
|
||||||
decodeFromBase64(hmacSecretWrapped),
|
decodeFromBase64(hmacSecretWrapped),
|
||||||
masterKey,
|
masterKey,
|
||||||
@@ -79,15 +70,15 @@ export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: Cry
|
|||||||
name: "HMAC",
|
name: "HMAC",
|
||||||
hash: "SHA-256",
|
hash: "SHA-256",
|
||||||
} satisfies HmacImportParams,
|
} satisfies HmacImportParams,
|
||||||
true, // Extractable
|
false, // Nonextractable
|
||||||
["sign", "verify"],
|
["sign", "verify"],
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
|
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_SIZE));
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||||
const ciphertext = await crypto.subtle.encrypt(
|
const ciphertext = await window.crypto.subtle.encrypt(
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: "AES-GCM",
|
||||||
iv,
|
iv,
|
||||||
@@ -95,18 +86,14 @@ export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
|
|||||||
dataKey,
|
dataKey,
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
return { ciphertext, iv: iv.buffer };
|
return { ciphertext, iv: encodeToBase64(iv.buffer) };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decryptData = async (
|
export const decryptData = async (ciphertext: BufferSource, iv: string, dataKey: CryptoKey) => {
|
||||||
ciphertext: BufferSource,
|
return await window.crypto.subtle.decrypt(
|
||||||
iv: string | BufferSource,
|
|
||||||
dataKey: CryptoKey,
|
|
||||||
) => {
|
|
||||||
return await crypto.subtle.decrypt(
|
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: "AES-GCM",
|
||||||
iv: typeof iv === "string" ? decodeFromBase64(iv) : iv,
|
iv: decodeFromBase64(iv),
|
||||||
} satisfies AesGcmParams,
|
} satisfies AesGcmParams,
|
||||||
dataKey,
|
dataKey,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
@@ -115,22 +102,9 @@ export const decryptData = async (
|
|||||||
|
|
||||||
export const encryptString = async (plaintext: string, dataKey: CryptoKey) => {
|
export const encryptString = async (plaintext: string, dataKey: CryptoKey) => {
|
||||||
const { ciphertext, iv } = await encryptData(encodeString(plaintext), dataKey);
|
const { ciphertext, iv } = await encryptData(encodeString(plaintext), dataKey);
|
||||||
return { ciphertext: encodeToBase64(ciphertext), iv: encodeToBase64(iv) };
|
return { ciphertext: encodeToBase64(ciphertext), iv };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decryptString = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
|
export const decryptString = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
|
||||||
return decodeString(await decryptData(decodeFromBase64(ciphertext), iv, dataKey));
|
return decodeString(await decryptData(decodeFromBase64(ciphertext), iv, dataKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const encryptChunk = async (chunk: ArrayBuffer, dataKey: CryptoKey) => {
|
|
||||||
const { ciphertext, iv } = await encryptData(chunk, dataKey);
|
|
||||||
return concatenateBuffers(iv, ciphertext).buffer;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const decryptChunk = async (encryptedChunk: ArrayBuffer, dataKey: CryptoKey) => {
|
|
||||||
return await decryptData(
|
|
||||||
encryptedChunk.slice(AES_GCM_IV_SIZE),
|
|
||||||
encryptedChunk.slice(0, AES_GCM_IV_SIZE),
|
|
||||||
dataKey,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./aes";
|
||||||
|
export * from "./rsa";
|
||||||
|
export * from "./sha";
|
||||||
|
export * from "./util";
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { encodeString, encodeToBase64, decodeFromBase64 } from "./encoding.ts";
|
import { encodeString, encodeToBase64, decodeFromBase64 } from "./util";
|
||||||
|
|
||||||
export const generateEncryptionKeyPair = async () => {
|
export const generateEncryptionKeyPair = async () => {
|
||||||
const keyPair = await crypto.subtle.generateKey(
|
const keyPair = await window.crypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: "RSA-OAEP",
|
name: "RSA-OAEP",
|
||||||
modulusLength: 4096,
|
modulusLength: 4096,
|
||||||
@@ -18,7 +18,7 @@ export const generateEncryptionKeyPair = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const generateSigningKeyPair = async () => {
|
export const generateSigningKeyPair = async () => {
|
||||||
const keyPair = await crypto.subtle.generateKey(
|
const keyPair = await window.crypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: "RSA-PSS",
|
name: "RSA-PSS",
|
||||||
modulusLength: 4096,
|
modulusLength: 4096,
|
||||||
@@ -37,7 +37,7 @@ export const generateSigningKeyPair = async () => {
|
|||||||
export const exportRSAKey = async (key: CryptoKey) => {
|
export const exportRSAKey = async (key: CryptoKey) => {
|
||||||
const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const);
|
const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const);
|
||||||
return {
|
return {
|
||||||
key: await crypto.subtle.exportKey(format, key),
|
key: await window.crypto.subtle.exportKey(format, key),
|
||||||
format,
|
format,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -46,63 +46,19 @@ export const exportRSAKeyToBase64 = async (key: CryptoKey) => {
|
|||||||
return encodeToBase64((await exportRSAKey(key)).key);
|
return encodeToBase64((await exportRSAKey(key)).key);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const importEncryptionKeyPairFromBase64 = async (
|
|
||||||
encryptKeyBase64: string,
|
|
||||||
decryptKeyBase64: string,
|
|
||||||
) => {
|
|
||||||
const algorithm: RsaHashedImportParams = {
|
|
||||||
name: "RSA-OAEP",
|
|
||||||
hash: "SHA-256",
|
|
||||||
};
|
|
||||||
const encryptKey = await crypto.subtle.importKey(
|
|
||||||
"spki",
|
|
||||||
decodeFromBase64(encryptKeyBase64),
|
|
||||||
algorithm,
|
|
||||||
true,
|
|
||||||
["encrypt", "wrapKey"],
|
|
||||||
);
|
|
||||||
const decryptKey = await crypto.subtle.importKey(
|
|
||||||
"pkcs8",
|
|
||||||
decodeFromBase64(decryptKeyBase64),
|
|
||||||
algorithm,
|
|
||||||
true,
|
|
||||||
["decrypt", "unwrapKey"],
|
|
||||||
);
|
|
||||||
return { encryptKey, decryptKey };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const importSigningKeyPairFromBase64 = async (
|
|
||||||
signKeyBase64: string,
|
|
||||||
verifyKeyBase64: string,
|
|
||||||
) => {
|
|
||||||
const algorithm: RsaHashedImportParams = {
|
|
||||||
name: "RSA-PSS",
|
|
||||||
hash: "SHA-256",
|
|
||||||
};
|
|
||||||
const signKey = await crypto.subtle.importKey(
|
|
||||||
"pkcs8",
|
|
||||||
decodeFromBase64(signKeyBase64),
|
|
||||||
algorithm,
|
|
||||||
true,
|
|
||||||
["sign"],
|
|
||||||
);
|
|
||||||
const verifyKey = await crypto.subtle.importKey(
|
|
||||||
"spki",
|
|
||||||
decodeFromBase64(verifyKeyBase64),
|
|
||||||
algorithm,
|
|
||||||
true,
|
|
||||||
["verify"],
|
|
||||||
);
|
|
||||||
return { signKey, verifyKey };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const makeRSAKeyNonextractable = async (key: CryptoKey) => {
|
export const makeRSAKeyNonextractable = async (key: CryptoKey) => {
|
||||||
const { key: exportedKey, format } = await exportRSAKey(key);
|
const { key: exportedKey, format } = await exportRSAKey(key);
|
||||||
return await crypto.subtle.importKey(format, exportedKey, key.algorithm, false, key.usages);
|
return await window.crypto.subtle.importKey(
|
||||||
|
format,
|
||||||
|
exportedKey,
|
||||||
|
key.algorithm,
|
||||||
|
false,
|
||||||
|
key.usages,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) => {
|
export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) => {
|
||||||
return await crypto.subtle.decrypt(
|
return await window.crypto.subtle.decrypt(
|
||||||
{
|
{
|
||||||
name: "RSA-OAEP",
|
name: "RSA-OAEP",
|
||||||
} satisfies RsaOaepParams,
|
} satisfies RsaOaepParams,
|
||||||
@@ -113,7 +69,7 @@ export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey)
|
|||||||
|
|
||||||
export const wrapMasterKey = async (masterKey: CryptoKey, encryptKey: CryptoKey) => {
|
export const wrapMasterKey = async (masterKey: CryptoKey, encryptKey: CryptoKey) => {
|
||||||
return encodeToBase64(
|
return encodeToBase64(
|
||||||
await crypto.subtle.wrapKey("raw", masterKey, encryptKey, {
|
await window.crypto.subtle.wrapKey("raw", masterKey, encryptKey, {
|
||||||
name: "RSA-OAEP",
|
name: "RSA-OAEP",
|
||||||
} satisfies RsaOaepParams),
|
} satisfies RsaOaepParams),
|
||||||
);
|
);
|
||||||
@@ -125,7 +81,7 @@ export const unwrapMasterKey = async (
|
|||||||
extractable = false,
|
extractable = false,
|
||||||
) => {
|
) => {
|
||||||
return {
|
return {
|
||||||
masterKey: await crypto.subtle.unwrapKey(
|
masterKey: await window.crypto.subtle.unwrapKey(
|
||||||
"raw",
|
"raw",
|
||||||
decodeFromBase64(masterKeyWrapped),
|
decodeFromBase64(masterKeyWrapped),
|
||||||
decryptKey,
|
decryptKey,
|
||||||
@@ -140,7 +96,7 @@ export const unwrapMasterKey = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => {
|
export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => {
|
||||||
return await crypto.subtle.sign(
|
return await window.crypto.subtle.sign(
|
||||||
{
|
{
|
||||||
name: "RSA-PSS",
|
name: "RSA-PSS",
|
||||||
saltLength: 32, // SHA-256
|
saltLength: 32, // SHA-256
|
||||||
@@ -155,7 +111,7 @@ export const verifySignatureRSA = async (
|
|||||||
signature: BufferSource,
|
signature: BufferSource,
|
||||||
verifyKey: CryptoKey,
|
verifyKey: CryptoKey,
|
||||||
) => {
|
) => {
|
||||||
return await crypto.subtle.verify(
|
return await window.crypto.subtle.verify(
|
||||||
{
|
{
|
||||||
name: "RSA-PSS",
|
name: "RSA-PSS",
|
||||||
saltLength: 32, // SHA-256
|
saltLength: 32, // SHA-256
|
||||||
@@ -166,21 +122,15 @@ export const verifySignatureRSA = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The message signed by clients to prove the integrity of the wrapped master key. Also verified
|
|
||||||
// by the server, so it must not be changed.
|
|
||||||
export const serializeMasterKeyWrapped = (masterKeyWrapped: string, masterKeyVersion: number) => {
|
|
||||||
return JSON.stringify({
|
|
||||||
version: masterKeyVersion,
|
|
||||||
key: masterKeyWrapped,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const signMasterKeyWrapped = async (
|
export const signMasterKeyWrapped = async (
|
||||||
masterKeyWrapped: string,
|
masterKeyWrapped: string,
|
||||||
masterKeyVersion: number,
|
masterKeyVersion: number,
|
||||||
signKey: CryptoKey,
|
signKey: CryptoKey,
|
||||||
) => {
|
) => {
|
||||||
const serialized = serializeMasterKeyWrapped(masterKeyWrapped, masterKeyVersion);
|
const serialized = JSON.stringify({
|
||||||
|
version: masterKeyVersion,
|
||||||
|
key: masterKeyWrapped,
|
||||||
|
});
|
||||||
return encodeToBase64(await signMessageRSA(encodeString(serialized), signKey));
|
return encodeToBase64(await signMessageRSA(encodeString(serialized), signKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,7 +140,10 @@ export const verifyMasterKeyWrapped = async (
|
|||||||
masterKeyWrappedSig: string,
|
masterKeyWrappedSig: string,
|
||||||
verifyKey: CryptoKey,
|
verifyKey: CryptoKey,
|
||||||
) => {
|
) => {
|
||||||
const serialized = serializeMasterKeyWrapped(masterKeyWrapped, masterKeyVersion);
|
const serialized = JSON.stringify({
|
||||||
|
version: masterKeyVersion,
|
||||||
|
key: masterKeyWrapped,
|
||||||
|
});
|
||||||
return await verifySignatureRSA(
|
return await verifySignatureRSA(
|
||||||
encodeString(serialized),
|
encodeString(serialized),
|
||||||
decodeFromBase64(masterKeyWrappedSig),
|
decodeFromBase64(masterKeyWrappedSig),
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export const digestMessage = async (message: BufferSource) => {
|
||||||
|
return await window.crypto.subtle.digest("SHA-256", message);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateHmacSecret = async () => {
|
||||||
|
return {
|
||||||
|
hmacSecret: await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: "HMAC",
|
||||||
|
hash: "SHA-256",
|
||||||
|
} satisfies HmacKeyGenParams,
|
||||||
|
true,
|
||||||
|
["sign", "verify"],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const signMessageHmac = async (message: BufferSource, hmacSecret: CryptoKey) => {
|
||||||
|
return await window.crypto.subtle.sign("HMAC", hmacSecret, message);
|
||||||
|
};
|
||||||
@@ -9,12 +9,12 @@ export const decodeString = (data: ArrayBuffer) => {
|
|||||||
return textDecoder.decode(data);
|
return textDecoder.decode(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const encodeToBase64 = (data: ArrayBuffer | Uint8Array) => {
|
export const encodeToBase64 = (data: ArrayBuffer) => {
|
||||||
return (data instanceof ArrayBuffer ? new Uint8Array(data) : data).toBase64();
|
return btoa(String.fromCharCode(...new Uint8Array(data)));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decodeFromBase64 = (data: string) => {
|
export const decodeFromBase64 = (data: string) => {
|
||||||
return Uint8Array.fromBase64(data).buffer;
|
return Uint8Array.from(atob(data), (c) => c.charCodeAt(0)).buffer;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const concatenateBuffers = (...buffers: ArrayBuffer[]) => {
|
export const concatenateBuffers = (...buffers: ArrayBuffer[]) => {
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { writable, type Writable } from "svelte/store";
|
||||||
|
import { callGetApi } from "$lib/hooks";
|
||||||
|
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
|
||||||
|
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
|
||||||
|
import {
|
||||||
|
directoryInfoStore,
|
||||||
|
fileInfoStore,
|
||||||
|
type DirectoryInfo,
|
||||||
|
type FileInfo,
|
||||||
|
} from "$lib/stores/file";
|
||||||
|
|
||||||
|
const fetchDirectoryInfo = async (
|
||||||
|
directoryId: "root" | number,
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
infoStore: Writable<DirectoryInfo | null>,
|
||||||
|
) => {
|
||||||
|
const res = await callGetApi(`/api/directory/${directoryId}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch directory information");
|
||||||
|
const { metadata, subDirectories, files }: DirectoryInfoResponse = await res.json();
|
||||||
|
|
||||||
|
let newInfo: DirectoryInfo;
|
||||||
|
if (directoryId === "root") {
|
||||||
|
newInfo = {
|
||||||
|
id: "root",
|
||||||
|
subDirectoryIds: subDirectories,
|
||||||
|
fileIds: files,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
|
||||||
|
newInfo = {
|
||||||
|
id: directoryId,
|
||||||
|
dataKey,
|
||||||
|
dataKeyVersion: new Date(metadata!.dekVersion),
|
||||||
|
name: await decryptString(metadata!.name, metadata!.nameIv, dataKey),
|
||||||
|
subDirectoryIds: subDirectories,
|
||||||
|
fileIds: files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
infoStore.update(() => newInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDirectoryInfo = (directoryId: "root" | number, masterKey: CryptoKey) => {
|
||||||
|
// TODO: MEK rotation
|
||||||
|
|
||||||
|
let info = directoryInfoStore.get(directoryId);
|
||||||
|
if (!info) {
|
||||||
|
info = writable(null);
|
||||||
|
directoryInfoStore.set(directoryId, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDirectoryInfo(directoryId, masterKey, info);
|
||||||
|
return info;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFileInfo = async (
|
||||||
|
fileId: number,
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
infoStore: Writable<FileInfo | null>,
|
||||||
|
) => {
|
||||||
|
const res = await callGetApi(`/api/file/${fileId}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch file information");
|
||||||
|
const metadata: FileInfoResponse = await res.json();
|
||||||
|
|
||||||
|
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
|
||||||
|
const newInfo: FileInfo = {
|
||||||
|
id: fileId,
|
||||||
|
dataKey,
|
||||||
|
dataKeyVersion: new Date(metadata.dekVersion),
|
||||||
|
contentType: metadata.contentType,
|
||||||
|
contentIv: metadata.contentIv,
|
||||||
|
name: await decryptString(metadata.name, metadata.nameIv, dataKey),
|
||||||
|
};
|
||||||
|
|
||||||
|
infoStore.update(() => newInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
|
||||||
|
// TODO: MEK rotation
|
||||||
|
|
||||||
|
let info = fileInfoStore.get(fileId);
|
||||||
|
if (!info) {
|
||||||
|
info = writable(null);
|
||||||
|
fileInfoStore.set(fileId, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchFileInfo(fileId, masterKey, info);
|
||||||
|
return info;
|
||||||
|
};
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { ClientRepo, SessionRepo, db } from "#lib/server/db/index.ts";
|
|
||||||
import env from "#lib/server/config.ts";
|
|
||||||
import { verifySessionId } from "#lib/server/crypto.ts";
|
|
||||||
import { AppError, invalidSessionError } from "#lib/server/errors.ts";
|
|
||||||
|
|
||||||
export interface Session {
|
|
||||||
sessionId: string;
|
|
||||||
userId: number;
|
|
||||||
clientId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClientSession extends Session {
|
|
||||||
clientId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionPermission =
|
|
||||||
"any" | "notClient" | "anyClient" | "pendingClient" | "activeClient";
|
|
||||||
|
|
||||||
export class AuthenticationError extends Error {
|
|
||||||
constructor(
|
|
||||||
public status: 400 | 401,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = "AuthenticationError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const cookieOptions = {
|
|
||||||
path: "/",
|
|
||||||
maxAge: env.session.exp / 1000,
|
|
||||||
secure: true,
|
|
||||||
sameSite: "strict",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const authenticate = async (sessionIdSigned: string, ip: string, userAgent: string) => {
|
|
||||||
const sessionId = verifySessionId(sessionIdSigned, env.session.secret);
|
|
||||||
if (!sessionId) {
|
|
||||||
throw new AuthenticationError(400, "Invalid session id");
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await SessionRepo.refreshSession(db, sessionId, ip, userAgent);
|
|
||||||
if (!session) {
|
|
||||||
throw new AuthenticationError(401, "Invalid session id");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: sessionId,
|
|
||||||
userId: session.userId,
|
|
||||||
clientId: session.clientId ?? undefined,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureUserClientState = async (
|
|
||||||
userId: number,
|
|
||||||
clientId: number | undefined,
|
|
||||||
state: "pending" | "active",
|
|
||||||
) => {
|
|
||||||
if (!clientId) {
|
|
||||||
throw new AppError("FORBIDDEN", "Forbidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
const userClient = await ClientRepo.getUserClient(db, userId, clientId);
|
|
||||||
if (!userClient) {
|
|
||||||
throw invalidSessionError();
|
|
||||||
} else if (userClient.state !== state) {
|
|
||||||
throw new AppError("FORBIDDEN", "Forbidden");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function authorize(
|
|
||||||
locals: App.Locals,
|
|
||||||
requiredPermission: "any" | "notClient",
|
|
||||||
): Promise<Session>;
|
|
||||||
|
|
||||||
export async function authorize(
|
|
||||||
locals: App.Locals,
|
|
||||||
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
|
|
||||||
): Promise<ClientSession>;
|
|
||||||
|
|
||||||
export async function authorize(
|
|
||||||
locals: App.Locals,
|
|
||||||
requiredPermission: SessionPermission,
|
|
||||||
): Promise<Session> {
|
|
||||||
if (!locals.session) {
|
|
||||||
throw new AppError("UNAUTHORIZED", "Unauthenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: sessionId, userId, clientId } = locals.session;
|
|
||||||
|
|
||||||
switch (requiredPermission) {
|
|
||||||
case "any":
|
|
||||||
break;
|
|
||||||
case "notClient":
|
|
||||||
if (clientId) {
|
|
||||||
throw new AppError("FORBIDDEN", "Forbidden");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "anyClient":
|
|
||||||
if (!clientId) {
|
|
||||||
throw new AppError("FORBIDDEN", "Forbidden");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "pendingClient":
|
|
||||||
await ensureUserClientState(userId, clientId, "pending");
|
|
||||||
break;
|
|
||||||
case "activeClient":
|
|
||||||
await ensureUserClientState(userId, clientId, "active");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sessionId, userId, clientId };
|
|
||||||
}
|
|
||||||
Loaded 100 of 408 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user