47 Commits

Author SHA1 Message Date
static
aef43b8bfa Merge pull request #6 from kmc7468/dev
v0.3.0
2025-01-18 13:29:09 +09:00
static
b8e1584575 사소한 리팩토링 2 2025-01-18 13:18:07 +09:00
static
c24e84a79c 불필요한 CSS 속성 제거 2 2025-01-18 13:09:51 +09:00
static
3d620d716d 불필요한 CSS 속성 제거 2025-01-18 13:00:16 +09:00
static
da47a07da7 사소한 리팩토링 2025-01-18 12:48:01 +09:00
static
d0d4afd2c3 다운로드 및 업로드 속도가 잘못 표기되던 버그 수정 2025-01-18 11:23:22 +09:00
static
811713cd03 파일 업로드/다운로드 현황을 모두 볼 수 있는 페이지 구현 2025-01-18 10:26:35 +09:00
static
bde090c464 파일 다운로드 스케쥴링 및 진행률 표시 기능 구현 2025-01-18 08:20:09 +09:00
static
620d174e9b 클라이언트가 시작될 때 삭제된 파일이나 디렉터리 정보를 IndexedDB에서 삭제하도록 개선 2025-01-17 13:02:21 +09:00
static
7aa6ba0eab 파일 및 디렉터리 목록을 IndexedDB에 캐싱하도록 구현 2025-01-17 12:22:51 +09:00
static
7e711c1b8f /api/file/upload Endpoint에서의 dekVersion 제한 완화 및 파일 업로드 중 페이지를 떠나려는 경우 경고 표시 기능 구현
dekVersion의 경우, Request를 받은 시점으로부터 하루 전 ~ 1분 후 사이에 있어야 하도록 완화했습니다. 기존에는 1분 전 ~ 1분 후 사이에 있어야 했습니다. 파일을 한 번에 업로드하는 경우 오류가 발생하는 것을 방지하기 위한 조치입니다.
2025-01-17 07:54:09 +09:00
static
0ed3d17fef 여러 파일을 한 번에 업로드할 수 있도록 변경 2025-01-17 07:39:01 +09:00
static
40745d5da4 파일/디렉터리 목록에서 무한한 Request가 발생하던 버그 수정 2025-01-16 04:25:20 +09:00
static
cc9d355ac1 파일을 업로드하는 중에, 해당되는 디렉터리에 업로드 Progress를 표시하도록 구현 2025-01-16 04:23:31 +09:00
static
937c4e2453 파일 업로드 스케쥴링 구현
암호화는 동시에 최대 4개까지, 업로드는 1개까지 가능하도록 설정했습니다.
2025-01-16 02:33:00 +09:00
static
366f657113 heic2any를 동적으로 import하도록 변경 2025-01-15 08:34:50 +09:00
static
9f9c52ff94 파일을 업로드하는 도중에 HTTP 연결이 끊기면 서버가 크래시되던 버그 수정 2025-01-15 06:09:27 +09:00
static
ed4da7b1df 파일 업로드에 5분 이상 걸리는 경우 408 오류가 발생하던 버그 수정 2025-01-15 02:53:14 +09:00
static
f4b9f87087 파일을 업로드할 때 스트리밍이 되지 않고 버퍼링하던 버그 수정 2025-01-14 18:06:41 +09:00
static
6015a9bca4 캐시되지 않은 파일의 캐시를 삭제하려고 시도하던 버그 수정 2025-01-14 03:39:44 +09:00
static
4bd666a5d5 디렉터리를 삭제하는 경우, 디렉터리 하위에 있던 파일의 캐시를 자동으로 삭제하도록 구현 2025-01-14 03:37:31 +09:00
static
27d2b83464 캐시 삭제 구현 2025-01-14 03:26:32 +09:00
static
f37df53991 캐시 목록 페이지 추가 2025-01-14 03:07:54 +09:00
static
ea0f0e4a71 파일 캐시 추가 2025-01-14 01:03:26 +09:00
static
9ab107794a 로그아웃 버튼 추가 2025-01-13 07:40:10 +09:00
static
919a67fedf DB 마이그레이션 스크립트 생성 2025-01-13 07:27:42 +09:00
static
f914026922 파일 생성 시각 및 파일 마지막 수정 시각을 저장하도록 변경
파일 마지막 수정 시각은 반드시 지정되어야 하며, 파일 시스템에서 읽어옵니다. 파일 생성 시각은 선택적으로 지정될 수 있으며, 이미지일 경우 EXIF에서 추출을 시도합니다. 두 값 모두 클라이언트에서 암호화되어 서버에 저장됩니다.
2025-01-13 07:06:31 +09:00
static
7f128cccf6 Merge pull request #5 from kmc7468/dev
v0.2.0
2025-01-13 03:53:14 +09:00
static
8a620fac78 누락된 throw 추가 2025-01-13 03:44:09 +09:00
static
b8c7cda4d5 사소한 리팩토링 2025-01-13 03:33:01 +09:00
static
6a64bb45f2 마지막 접속 IP와 User Agent가 빈 값인 경우 DB에 해당 정보를 기록하지 않도록 수정 2025-01-13 03:17:54 +09:00
static
68a764bf28 DB 마이그레이션 스크립트 재생성 2025-01-13 02:54:28 +09:00
static
8bb4d70fa5 비밀번호 변경 페이지 구현 2025-01-13 02:53:32 +09:00
static
299787537e /api/auth/changePassword, /api/user, /api/user/changeNickname Endpoint 구현 2025-01-13 01:57:07 +09:00
static
bd1e9cf54f Merge pull request #4 from kmc7468/add-hmac-secret-key
파일 중복 검사 시스템 도입
2025-01-13 01:19:32 +09:00
static
e887fcf137 DB 마이그레이션 스크립트 재생성 2025-01-13 00:48:08 +09:00
static
5c7dc58f03 프론트엔드에서의 파일 업로드 전 중복 검사 구현 2025-01-13 00:46:35 +09:00
static
59c8523e25 암호 키 생성 및 등록시 HSK도 함께 생성 및 등록하도록 변경 2025-01-12 21:52:41 +09:00
static
805d7df182 /api/hsk/list, /api/hsk/register/initial Endpoint 구현 2025-01-12 20:26:48 +09:00
static
004e41b0cf MEK 등록시 로그를 남기도록 변경 2025-01-12 19:22:21 +09:00
static
f8115f4f2e 파일/디렉터리 생성/이름 변경시 로그를 남기도록 변경 2025-01-12 19:02:21 +09:00
static
aebbc6d0c0 Merge pull request #3 from kmc7468/switch-to-session-auth
세션 기반 인증으로 마이그레이션
2025-01-12 09:17:47 +09:00
static
85ebb529ba 프론트엔드에서 세션 ID 기반 인증 대응 및 DB 마이그레이션 스크립트 재생성 2025-01-12 08:31:11 +09:00
static
be8587694e 암호 키 등록 챌린지 처리 방식을 세션 업그레이드 챌린지 처리 방식과 동일하게 변경 2025-01-12 07:59:49 +09:00
static
1a86c8d9e0 백엔드에서 JWT가 아닌 세션 ID 기반으로 인증하도록 변경 2025-01-12 07:28:38 +09:00
static
0bdf990dae DB에 동시적으로 접근하더라도 데이터 무결성이 깨지지 않도록 DB 접근 코드 수정 2025-01-11 03:55:19 +09:00
static
045eb69487 Node.js, pnpm 및 기타 의존성 버전 업데이트
- Node.js 18에서 Node.js 22로 업데이트하였습니다.
- pnpm 8에서 pnpm 9으로 업데이트하였습니다.
- 기타 의존성은 메이저 버전이 바뀌지 않는 선에서 최신 버전으로 업데이트하였습니다.
2025-01-09 20:29:49 +09:00
135 changed files with 7931 additions and 3608 deletions

View File

@@ -1,10 +1,9 @@
# Required environment variables
JWT_SECRET=
SESSION_SECRET=
# Optional environment variables
DATABASE_URL=
JWT_ACCESS_TOKEN_EXPIRES=
JWT_REFRESH_TOKEN_EXPIRES=
SESSION_EXPIRES=
USER_CLIENT_CHALLENGE_EXPIRES=
TOKEN_UPGRADE_CHALLENGE_EXPIRES=
SESSION_UPGRADE_CHALLENGE_EXPIRES=
LIBRARY_PATH=

View File

@@ -1,8 +1,8 @@
# Base Image
FROM node:18-alpine AS base
FROM node:22-alpine AS base
WORKDIR /app
RUN npm install -g pnpm@8
RUN npm install -g pnpm@9
COPY pnpm-lock.yaml .
# Build Stage
@@ -13,6 +13,8 @@ COPY . .
RUN pnpm install --offline
RUN pnpm build
RUN sed -i "s/http\.createServer()/http.createServer({ requestTimeout: 0 })/g" ./build/index.js
# Deploy Stage
FROM base
RUN pnpm fetch --prod

View File

@@ -30,12 +30,11 @@ docker compose up --build -d
필수 환경 변수가 아닌 경우, 설정해야 하는 특별한 이유가 없다면 기본값을 사용하는 것이 좋아요.
|이름|필수|기본값|설명|
|-:|:-:|:-:|:-|
|`JWT_SECRET`|Y||JWT의 서명을 위해 사용돼요. 안전한 값으로 설정해 주세요.|
|`JWT_ACCESS_TOKEN_EXPIRES`||`5m`|Access Token의 유효 시간이에요.|
|`JWT_REFRESH_TOKEN_EXPIRES`||`14d`|Refresh Token의 유효 시간이에요.|
|:-|:-:|:-:|:-|
|`SESSION_SECRET`|Y||Session ID의 서명을 위해 사용돼요. 안전한 값으로 설정해 주세요.|
|`SESSION_EXPIRES`||`14d`|Session의 유효 시간이에요. Session은 마지막으로 사용된 후 설정된 유효 시간이 지나면 자동으로 삭제돼요.|
|`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.|
|`TOKEN_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.|
|`SESSION_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.|
|`TRUST_PROXY`|||신뢰할 수 있는 리버스 프록시의 수예요. 설정할 경우 1 이상의 정수로 설정해 주세요. 프록시에서 `X-Forwarded-For` HTTP 헤더를 올바르게 설정하도록 구성해 주세요.|
|`NODE_ENV`||`production`|ArkVault의 사용 용도예요. `production`인 경우, 컨테이너가 실행될 때마다 DB 마이그레이션이 자동으로 실행돼요.|
|`PORT`||`80`|ArkVault 서버의 포트예요.|

View File

@@ -8,11 +8,10 @@ services:
environment:
# ArkVault
- DATABASE_URL=/app/data/database.sqlite
- JWT_SECRET=${JWT_SECRET:?} # Required
- JWT_ACCESS_TOKEN_EXPIRES
- JWT_REFRESH_TOKEN_EXPIRES
- SESSION_SECRET=${SESSION_SECRET:?} # Required
- SESSION_EXPIRES
- USER_CLIENT_CHALLENGE_EXPIRES
- TOKEN_UPGRADE_CHALLENGE_EXPIRES
- SESSION_UPGRADE_CHALLENGE_EXPIRES
- LIBRARY_PATH=/app/data/library
# SvelteKit
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}

View File

@@ -17,17 +17,16 @@ CREATE TABLE `user_client_challenge` (
`id` integer PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`client_id` integer NOT NULL,
`challenge` text NOT NULL,
`answer` text NOT NULL,
`allowed_ip` text NOT NULL,
`expires_at` integer NOT NULL,
`is_used` integer DEFAULT false 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 (`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,
`created_at` integer NOT NULL,
`parent_id` integer,
`user_id` integer NOT NULL,
`master_encryption_key_version` integer NOT NULL,
@@ -39,23 +38,66 @@ CREATE TABLE `directory` (
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,
`path` text NOT NULL,
`parent_id` integer,
`created_at` integer NOT NULL,
`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,
@@ -71,49 +113,63 @@ CREATE TABLE `client_master_encryption_key` (
CREATE TABLE `master_encryption_key` (
`user_id` integer NOT NULL,
`version` integer NOT NULL,
`created_by` integer NOT NULL,
`created_at` 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,
FOREIGN KEY (`created_by`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `refresh_token` (
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,
`expires_at` integer NOT NULL,
`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 `token_upgrade_challenge` (
CREATE TABLE `session_upgrade_challenge` (
`id` integer PRIMARY KEY NOT NULL,
`refresh_token_id` text NOT NULL,
`session_id` text NOT NULL,
`client_id` integer NOT NULL,
`challenge` text NOT NULL,
`answer` text NOT NULL,
`allowed_ip` text NOT NULL,
`expires_at` integer NOT NULL,
`is_used` integer DEFAULT false NOT NULL,
FOREIGN KEY (`refresh_token_id`) REFERENCES `refresh_token`(`id`) ON UPDATE no action ON DELETE no action,
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
`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_challenge_unique` ON `user_client_challenge` (`challenge`);--> 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 `refresh_token_user_id_client_id_unique` ON `refresh_token` (`user_id`,`client_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `token_upgrade_challenge_challenge_unique` ON `token_upgrade_challenge` (`challenge`);--> 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`);

View File

@@ -0,0 +1,2 @@
ALTER TABLE `file` ADD `encrypted_created_at` text;--> statement-breakpoint
ALTER TABLE `file` ADD `encrypted_last_modified_at` text NOT NULL;

View File

@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "929c6bca-d0c0-4899-afc6-a0a498226f28",
"id": "928e5669-81cf-486c-9122-8ee64fc9f457",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"client": {
@@ -147,8 +147,8 @@
"notNull": true,
"autoincrement": false
},
"challenge": {
"name": "challenge",
"answer": {
"name": "answer",
"type": "text",
"primaryKey": false,
"notNull": true,
@@ -167,21 +167,13 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_used": {
"name": "is_used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"user_client_challenge_challenge_unique": {
"name": "user_client_challenge_challenge_unique",
"user_client_challenge_answer_unique": {
"name": "user_client_challenge_answer_unique",
"columns": [
"challenge"
"answer"
],
"isUnique": true
}
@@ -212,6 +204,21 @@
],
"onDelete": "no action",
"onUpdate": "no action"
},
"user_client_challenge_user_id_client_id_user_client_user_id_client_id_fk": {
"name": "user_client_challenge_user_id_client_id_user_client_user_id_client_id_fk",
"tableFrom": "user_client_challenge",
"tableTo": "user_client",
"columnsFrom": [
"user_id",
"client_id"
],
"columnsTo": [
"user_id",
"client_id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
@@ -227,13 +234,6 @@
"notNull": true,
"autoincrement": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "integer",
@@ -332,6 +332,64 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"directory_log": {
"name": "directory_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"directory_id": {
"name": "directory_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"new_name": {
"name": "new_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"directory_log_directory_id_directory_id_fk": {
"name": "directory_log_directory_id_directory_id_fk",
"tableFrom": "directory_log",
"tableTo": "directory",
"columnsFrom": [
"directory_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"file": {
"name": "file",
"columns": {
@@ -342,13 +400,6 @@
"notNull": true,
"autoincrement": true
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "integer",
@@ -356,16 +407,16 @@
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -391,6 +442,20 @@
"notNull": true,
"autoincrement": false
},
"hmac_secret_key_version": {
"name": "hmac_secret_key_version",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_hmac": {
"name": "content_hmac",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
@@ -470,6 +535,261 @@
],
"onDelete": "no action",
"onUpdate": "no action"
},
"file_user_id_hmac_secret_key_version_hmac_secret_key_user_id_version_fk": {
"name": "file_user_id_hmac_secret_key_version_hmac_secret_key_user_id_version_fk",
"tableFrom": "file",
"tableTo": "hmac_secret_key",
"columnsFrom": [
"user_id",
"hmac_secret_key_version"
],
"columnsTo": [
"user_id",
"version"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"file_log": {
"name": "file_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"file_id": {
"name": "file_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"new_name": {
"name": "new_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"file_log_file_id_file_id_fk": {
"name": "file_log_file_id_file_id_fk",
"tableFrom": "file_log",
"tableTo": "file",
"columnsFrom": [
"file_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"hmac_secret_key": {
"name": "hmac_secret_key",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"master_encryption_key_version": {
"name": "master_encryption_key_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_key": {
"name": "encrypted_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"hmac_secret_key_encrypted_key_unique": {
"name": "hmac_secret_key_encrypted_key_unique",
"columns": [
"encrypted_key"
],
"isUnique": true
}
},
"foreignKeys": {
"hmac_secret_key_user_id_user_id_fk": {
"name": "hmac_secret_key_user_id_user_id_fk",
"tableFrom": "hmac_secret_key",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"hmac_secret_key_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": {
"name": "hmac_secret_key_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk",
"tableFrom": "hmac_secret_key",
"tableTo": "master_encryption_key",
"columnsFrom": [
"user_id",
"master_encryption_key_version"
],
"columnsTo": [
"user_id",
"version"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"hmac_secret_key_user_id_version_pk": {
"columns": [
"user_id",
"version"
],
"name": "hmac_secret_key_user_id_version_pk"
}
},
"uniqueConstraints": {}
},
"hmac_secret_key_log": {
"name": "hmac_secret_key_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hmac_secret_key_version": {
"name": "hmac_secret_key_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action_by": {
"name": "action_by",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"hmac_secret_key_log_user_id_user_id_fk": {
"name": "hmac_secret_key_log_user_id_user_id_fk",
"tableFrom": "hmac_secret_key_log",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"hmac_secret_key_log_action_by_user_id_fk": {
"name": "hmac_secret_key_log_action_by_user_id_fk",
"tableFrom": "hmac_secret_key_log",
"tableTo": "user",
"columnsFrom": [
"action_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"hmac_secret_key_log_user_id_hmac_secret_key_version_hmac_secret_key_user_id_version_fk": {
"name": "hmac_secret_key_log_user_id_hmac_secret_key_version_hmac_secret_key_user_id_version_fk",
"tableFrom": "hmac_secret_key_log",
"tableTo": "hmac_secret_key",
"columnsFrom": [
"user_id",
"hmac_secret_key_version"
],
"columnsTo": [
"user_id",
"version"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
@@ -587,20 +907,6 @@
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
@@ -630,19 +936,6 @@
],
"onDelete": "no action",
"onUpdate": "no action"
},
"master_encryption_key_created_by_client_id_fk": {
"name": "master_encryption_key_created_by_client_id_fk",
"tableFrom": "master_encryption_key",
"tableTo": "client",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
@@ -656,8 +949,101 @@
},
"uniqueConstraints": {}
},
"refresh_token": {
"name": "refresh_token",
"master_encryption_key_log": {
"name": "master_encryption_key_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"master_encryption_key_version": {
"name": "master_encryption_key_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action_by": {
"name": "action_by",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"master_encryption_key_log_user_id_user_id_fk": {
"name": "master_encryption_key_log_user_id_user_id_fk",
"tableFrom": "master_encryption_key_log",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"master_encryption_key_log_action_by_client_id_fk": {
"name": "master_encryption_key_log_action_by_client_id_fk",
"tableFrom": "master_encryption_key_log",
"tableTo": "client",
"columnsFrom": [
"action_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"master_encryption_key_log_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": {
"name": "master_encryption_key_log_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk",
"tableFrom": "master_encryption_key_log",
"tableTo": "master_encryption_key",
"columnsFrom": [
"user_id",
"master_encryption_key_version"
],
"columnsTo": [
"user_id",
"version"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
@@ -680,17 +1066,38 @@
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_used_at": {
"name": "last_used_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_used_by_ip": {
"name": "last_used_by_ip",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_used_by_user_agent": {
"name": "last_used_by_user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"refresh_token_user_id_client_id_unique": {
"name": "refresh_token_user_id_client_id_unique",
"session_user_id_client_id_unique": {
"name": "session_user_id_client_id_unique",
"columns": [
"user_id",
"client_id"
@@ -699,9 +1106,9 @@
}
},
"foreignKeys": {
"refresh_token_user_id_user_id_fk": {
"name": "refresh_token_user_id_user_id_fk",
"tableFrom": "refresh_token",
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
@@ -712,9 +1119,9 @@
"onDelete": "no action",
"onUpdate": "no action"
},
"refresh_token_client_id_client_id_fk": {
"name": "refresh_token_client_id_client_id_fk",
"tableFrom": "refresh_token",
"session_client_id_client_id_fk": {
"name": "session_client_id_client_id_fk",
"tableFrom": "session",
"tableTo": "client",
"columnsFrom": [
"client_id"
@@ -729,8 +1136,8 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"token_upgrade_challenge": {
"name": "token_upgrade_challenge",
"session_upgrade_challenge": {
"name": "session_upgrade_challenge",
"columns": {
"id": {
"name": "id",
@@ -739,8 +1146,8 @@
"notNull": true,
"autoincrement": false
},
"refresh_token_id": {
"name": "refresh_token_id",
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true,
@@ -753,8 +1160,8 @@
"notNull": true,
"autoincrement": false
},
"challenge": {
"name": "challenge",
"answer": {
"name": "answer",
"type": "text",
"primaryKey": false,
"notNull": true,
@@ -773,32 +1180,31 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_used": {
"name": "is_used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"token_upgrade_challenge_challenge_unique": {
"name": "token_upgrade_challenge_challenge_unique",
"session_upgrade_challenge_session_id_unique": {
"name": "session_upgrade_challenge_session_id_unique",
"columns": [
"challenge"
"session_id"
],
"isUnique": true
},
"session_upgrade_challenge_answer_unique": {
"name": "session_upgrade_challenge_answer_unique",
"columns": [
"answer"
],
"isUnique": true
}
},
"foreignKeys": {
"token_upgrade_challenge_refresh_token_id_refresh_token_id_fk": {
"name": "token_upgrade_challenge_refresh_token_id_refresh_token_id_fk",
"tableFrom": "token_upgrade_challenge",
"tableTo": "refresh_token",
"session_upgrade_challenge_session_id_session_id_fk": {
"name": "session_upgrade_challenge_session_id_session_id_fk",
"tableFrom": "session_upgrade_challenge",
"tableTo": "session",
"columnsFrom": [
"refresh_token_id"
"session_id"
],
"columnsTo": [
"id"
@@ -806,9 +1212,9 @@
"onDelete": "no action",
"onUpdate": "no action"
},
"token_upgrade_challenge_client_id_client_id_fk": {
"name": "token_upgrade_challenge_client_id_client_id_fk",
"tableFrom": "token_upgrade_challenge",
"session_upgrade_challenge_client_id_client_id_fk": {
"name": "session_upgrade_challenge_client_id_client_id_fk",
"tableFrom": "session_upgrade_challenge",
"tableTo": "client",
"columnsFrom": [
"client_id"
@@ -846,6 +1252,13 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nickname": {
"name": "nickname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,15 @@
{
"idx": 0,
"version": "6",
"when": 1736170919561,
"tag": "0000_handy_captain_marvel",
"when": 1736704436996,
"tag": "0000_unknown_stark_industries",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1736720831242,
"tag": "0001_blushing_alice",
"breakpoints": true
}
]

View File

@@ -1,7 +1,7 @@
{
"name": "arkvault",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -17,46 +17,52 @@
"db:studio": "drizzle-kit studio"
},
"devDependencies": {
"@eslint/compat": "^1.2.3",
"@eslint/compat": "^1.2.4",
"@iconify-json/material-symbols": "^1.2.12",
"@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/better-sqlite3": "^7.6.11",
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/better-sqlite3": "^7.6.12",
"@types/file-saver": "^2.0.7",
"@types/jsonwebtoken": "^9.0.7",
"@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.7",
"autoprefixer": "^10.4.20",
"axios": "^1.7.9",
"dexie": "^4.0.10",
"drizzle-kit": "^0.22.0",
"eslint": "^9.7.0",
"drizzle-kit": "^0.22.8",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"eslint-plugin-svelte": "^2.46.1",
"eslint-plugin-tailwindcss": "^3.17.5",
"exifreader": "^4.26.0",
"file-saver": "^2.0.5",
"globals": "^15.0.0",
"globals": "^15.14.0",
"heic2any": "^0.0.4",
"mime": "^4.0.6",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.5",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.9",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"p-limit": "^6.2.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"svelte": "^5.17.1",
"svelte-check": "^4.1.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript-eslint": "^8.19.1",
"unplugin-icons": "^0.22.0",
"vite": "^5.4.11"
},
"dependencies": {
"@fastify/busboy": "^3.1.1",
"argon2": "^0.41.1",
"better-sqlite3": "^11.1.2",
"better-sqlite3": "^11.7.2",
"drizzle-orm": "^0.33.0",
"jsonwebtoken": "^9.0.2",
"ms": "^2.1.3",
"node-schedule": "^2.1.1",
"uuid": "^11.0.3",
"uuid": "^11.0.4",
"zod": "^3.24.1"
},
"engines": {
"node": "^22.0.0",
"pnpm": "^9.0.0"
}
}

4534
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

14
src/app.d.ts vendored
View File

@@ -5,11 +5,15 @@ import "unplugin-icons/types/svelte";
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
interface Locals {
ip: string;
userAgent: string;
session?: {
id: string;
userId: number;
clientId?: number;
};
}
}
}

View File

@@ -1,6 +1,8 @@
import type { ClientInit } from "@sveltejs/kit";
import { getClientKey, getMasterKeys } from "$lib/indexedDB";
import { clientKeyStore, masterKeyStore } from "$lib/stores";
import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
import { prepareFileCache } from "$lib/modules/file";
import { prepareOpfs } from "$lib/modules/opfs";
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
const prepareClientKeyStore = async () => {
const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([
@@ -21,6 +23,21 @@ const prepareMasterKeyStore = async () => {
}
};
export const init: ClientInit = async () => {
await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore()]);
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 () => {
await Promise.all([
prepareFileCache(),
prepareClientKeyStore(),
prepareMasterKeyStore(),
prepareHmacSecretStore(),
prepareOpfs(),
]);
cleanupDanglingInfos(); // Intended
};

View File

@@ -1,34 +1,22 @@
import { redirect, type ServerInit, type Handle } from "@sveltejs/kit";
import type { ServerInit } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import schedule from "node-schedule";
import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
import { migrateDB } from "$lib/server/db/drizzle";
import {
cleanupExpiredRefreshTokens,
cleanupExpiredTokenUpgradeChallenges,
} from "$lib/server/db/token";
cleanupExpiredSessions,
cleanupExpiredSessionUpgradeChallenges,
} from "$lib/server/db/session";
import { authenticate, setAgentInfo } from "$lib/server/middlewares";
export const init: ServerInit = () => {
migrateDB();
schedule.scheduleJob("0 * * * *", () => {
cleanupExpiredUserClientChallenges();
cleanupExpiredRefreshTokens();
cleanupExpiredTokenUpgradeChallenges();
cleanupExpiredSessions();
cleanupExpiredSessionUpgradeChallenges();
});
};
export const handle: Handle = async ({ event, resolve }) => {
if (["/api", "/auth"].some((path) => event.url.pathname.startsWith(path))) {
return await resolve(event);
}
const accessToken = event.cookies.get("accessToken");
if (accessToken) {
return await resolve(event);
} else {
redirect(
302,
"/auth/login?redirect=" + encodeURIComponent(event.url.pathname + event.url.search),
);
}
};
export const handle = sequence(setAgentInfo, authenticate);

View File

@@ -29,7 +29,7 @@
onclick?.();
}, 100);
}}
class="{bgColorStyle} {fontColorStyle} h-12 w-full rounded-xl font-medium"
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?.()}

View File

@@ -3,15 +3,16 @@
import type { SvelteHTMLElements } from "svelte/elements";
interface Props {
icon?: Component<SvelteHTMLElements["svg"]>;
children: Snippet;
icon?: Component<SvelteHTMLElements["svg"]>;
topPadding?: boolean;
}
let { icon: Icon, children }: Props = $props();
let { topPadding = true, children, icon: Icon }: Props = $props();
</script>
<div>
<div class="box-content flex min-h-[10vh] items-center pt-4">
<div class="box-content flex min-h-[10vh] items-center {topPadding ? 'pt-4' : ''}">
{#if Icon}
<Icon class="text-5xl text-gray-600" />
{/if}

View File

@@ -1,35 +1,11 @@
export const refreshToken = async (fetchInternal = fetch) => {
return await fetchInternal("/api/auth/refreshToken", { method: "POST" });
export const callGetApi = async (input: RequestInfo, fetchInternal = fetch) => {
return await fetchInternal(input);
};
const callApi = async (input: RequestInfo, init?: RequestInit, fetchInternal = fetch) => {
let res = await fetchInternal(input, init);
if (res.status === 401) {
res = await refreshToken();
if (!res.ok) {
return res;
}
res = await fetchInternal(input, init);
}
return res;
};
export const callGetApi = async (input: RequestInfo, fetchInternal?: typeof fetch) => {
return await callApi(input, undefined, fetchInternal);
};
export const callPostApi = async <T>(
input: RequestInfo,
payload?: T,
fetchInternal?: typeof fetch,
) => {
return await callApi(
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,
},
fetchInternal,
);
});
};

View File

@@ -11,6 +11,7 @@ interface KeyExportState {
verifyKeyBase64: string;
masterKeyWrapped: string;
hmacSecretWrapped: string;
}
const useAutoNull = <T>(value: T | null) => {

View File

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

View File

@@ -0,0 +1,86 @@
import { Dexie, type EntityTable } from "dexie";
export type DirectoryId = "root" | number;
interface DirectoryInfo {
id: number;
parentId: DirectoryId;
name: string;
}
interface FileInfo {
id: number;
parentId: DirectoryId;
name: string;
contentType: string;
createdAt?: Date;
lastModifiedAt: Date;
}
const filesystem = new Dexie("filesystem") as Dexie & {
directory: EntityTable<DirectoryInfo, "id">;
file: EntityTable<FileInfo, "id">;
};
filesystem.version(1).stores({
directory: "id, parentId",
file: "id, parentId",
});
export const getDirectoryInfos = async (parentId: DirectoryId) => {
return await filesystem.directory.where({ parentId }).toArray();
};
export const getDirectoryInfo = async (id: number) => {
return await filesystem.directory.get(id);
};
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
await filesystem.directory.put(directoryInfo);
};
export const deleteDirectoryInfo = async (id: number) => {
await filesystem.directory.delete(id);
};
export const getFileInfos = async (parentId: DirectoryId) => {
return await filesystem.file.where({ parentId }).toArray();
};
export const getFileInfo = async (id: number) => {
return await filesystem.file.get(id);
};
export const storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.put(fileInfo);
};
export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id);
};
export const cleanupDanglingInfos = async () => {
const validDirectoryIds: number[] = [];
const validFileIds: number[] = [];
const queue: DirectoryId[] = ["root"];
while (true) {
const directoryId = queue.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);
queue.push(id);
});
files.forEach(({ id }) => validFileIds.push(id));
}
await Promise.all([
filesystem.directory.where("id").noneOf(validDirectoryIds).delete(),
filesystem.file.where("id").noneOf(validFileIds).delete(),
]);
};

View File

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

View File

@@ -7,22 +7,28 @@ interface ClientKey {
key: CryptoKey;
}
type MasterKeyState = "active" | "retired";
interface MasterKey {
version: number;
state: MasterKeyState;
state: "active" | "retired";
key: CryptoKey;
}
interface HmacSecret {
version: number;
state: "active";
secret: CryptoKey;
}
const keyStore = new Dexie("keyStore") as Dexie & {
clientKey: EntityTable<ClientKey, "usage">;
masterKey: EntityTable<MasterKey, "version">;
hmacSecret: EntityTable<HmacSecret, "version">;
};
keyStore.version(1).stores({
clientKey: "usage",
masterKey: "version",
hmacSecret: "version",
});
export const getClientKey = async (usage: ClientKeyUsage) => {
@@ -62,3 +68,14 @@ export const storeMasterKeys = async (keys: MasterKey[]) => {
}
await keyStore.masterKey.bulkPut(keys);
};
export const getHmacSecrets = async () => {
return await keyStore.hmacSecret.toArray();
};
export const storeHmacSecrets = async (secrets: HmacSecret[]) => {
if (secrets.some(({ secret }) => secret.extractable)) {
throw new Error("Hmac secrets must be nonextractable");
}
await keyStore.hmacSecret.bulkPut(secrets);
};

View File

@@ -55,6 +55,27 @@ export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey
};
};
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) => {
return {
hmacSecret: await window.crypto.subtle.unwrapKey(
"raw",
decodeFromBase64(hmacSecretWrapped),
masterKey,
"AES-KW",
{
name: "HMAC",
hash: "SHA-256",
} satisfies HmacImportParams,
false, // Nonextractable
["sign", "verify"],
),
};
};
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await window.crypto.subtle.encrypt(

View File

@@ -95,7 +95,7 @@ export const unwrapMasterKey = async (
};
};
export const signMessage = async (message: BufferSource, signKey: CryptoKey) => {
export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => {
return await window.crypto.subtle.sign(
{
name: "RSA-PSS",
@@ -106,7 +106,7 @@ export const signMessage = async (message: BufferSource, signKey: CryptoKey) =>
);
};
export const verifySignature = async (
export const verifySignatureRSA = async (
message: BufferSource,
signature: BufferSource,
verifyKey: CryptoKey,
@@ -131,7 +131,7 @@ export const signMasterKeyWrapped = async (
version: masterKeyVersion,
key: masterKeyWrapped,
});
return encodeToBase64(await signMessage(encodeString(serialized), signKey));
return encodeToBase64(await signMessageRSA(encodeString(serialized), signKey));
};
export const verifyMasterKeyWrapped = async (
@@ -144,7 +144,7 @@ export const verifyMasterKeyWrapped = async (
version: masterKeyVersion,
key: masterKeyWrapped,
});
return await verifySignature(
return await verifySignatureRSA(
encodeString(serialized),
decodeFromBase64(masterKeyWrappedSig),
verifyKey,

View File

@@ -1,3 +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);
};

View File

@@ -1,89 +0,0 @@
import { writable, type Writable } from "svelte/store";
import { callGetApi } from "$lib/hooks";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
import {
directoryInfoStore,
fileInfoStore,
type DirectoryInfo,
type FileInfo,
} from "$lib/stores/file";
const fetchDirectoryInfo = async (
directoryId: "root" | number,
masterKey: CryptoKey,
infoStore: Writable<DirectoryInfo | null>,
) => {
const res = await callGetApi(`/api/directory/${directoryId}`);
if (!res.ok) throw new Error("Failed to fetch directory information");
const { metadata, subDirectories, files }: DirectoryInfoResponse = await res.json();
let newInfo: DirectoryInfo;
if (directoryId === "root") {
newInfo = {
id: "root",
subDirectoryIds: subDirectories,
fileIds: files,
};
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
newInfo = {
id: directoryId,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name: await decryptString(metadata!.name, metadata!.nameIv, dataKey),
subDirectoryIds: subDirectories,
fileIds: files,
};
}
infoStore.update(() => newInfo);
};
export const getDirectoryInfo = (directoryId: "root" | number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = directoryInfoStore.get(directoryId);
if (!info) {
info = writable(null);
directoryInfoStore.set(directoryId, info);
}
fetchDirectoryInfo(directoryId, masterKey, info);
return info;
};
const fetchFileInfo = async (
fileId: number,
masterKey: CryptoKey,
infoStore: Writable<FileInfo | null>,
) => {
const res = await callGetApi(`/api/file/${fileId}`);
if (!res.ok) throw new Error("Failed to fetch file information");
const metadata: FileInfoResponse = await res.json();
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const newInfo: FileInfo = {
id: fileId,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name: await decryptString(metadata.name, metadata.nameIv, dataKey),
};
infoStore.update(() => newInfo);
};
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = fileInfoStore.get(fileId);
if (!info) {
info = writable(null);
fileInfoStore.set(fileId, info);
}
fetchFileInfo(fileId, masterKey, info);
return info;
};

View File

@@ -0,0 +1,50 @@
import {
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
storeFileCacheIndex,
deleteFileCacheIndex,
type FileCacheIndex,
} from "$lib/indexedDB";
import { readFile, writeFile, deleteFile } from "$lib/modules/opfs";
const fileCacheIndex = new Map<number, FileCacheIndex>();
export const prepareFileCache = async () => {
for (const cache of await getFileCacheIndexFromIndexedDB()) {
fileCacheIndex.set(cache.fileId, cache);
}
};
export const getFileCacheIndex = () => {
return Array.from(fileCacheIndex.values());
};
export const getFileCache = async (fileId: number) => {
const cacheIndex = fileCacheIndex.get(fileId);
if (!cacheIndex) return null;
cacheIndex.lastRetrievedAt = new Date();
storeFileCacheIndex(cacheIndex); // Intended
return await readFile(`/cache/${fileId}`);
};
export const storeFileCache = async (fileId: number, fileBuffer: ArrayBuffer) => {
const now = new Date();
await writeFile(`/cache/${fileId}`, fileBuffer);
const cacheIndex: FileCacheIndex = {
fileId,
cachedAt: now,
lastRetrievedAt: now,
size: fileBuffer.byteLength,
};
fileCacheIndex.set(fileId, cacheIndex);
await storeFileCacheIndex(cacheIndex);
};
export const deleteFileCache = async (fileId: number) => {
if (!fileCacheIndex.has(fileId)) return;
fileCacheIndex.delete(fileId);
await deleteFile(`/cache/${fileId}`);
await deleteFileCacheIndex(fileId);
};

View File

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

View File

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

View File

@@ -0,0 +1,225 @@
import axios from "axios";
import ExifReader from "exifreader";
import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import {
encodeToBase64,
generateDataKey,
wrapDataKey,
encryptData,
encryptString,
signMessageHmac,
} from "$lib/modules/crypto";
import type {
DuplicateFileScanRequest,
DuplicateFileScanResponse,
FileUploadRequest,
} from "$lib/server/schemas";
import {
fileUploadStatusStore,
type MasterKey,
type HmacSecret,
type FileUploadStatus,
} from "$lib/stores";
const requestDuplicateFileScan = limitFunction(
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
const fileBuffer = await file.arrayBuffer();
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
const res = await axios.post("/api/file/scanDuplicates", {
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
} satisfies DuplicateFileScanRequest);
const { files }: DuplicateFileScanResponse = res.data;
if (files.length === 0 || (await onDuplicate())) {
return { fileBuffer, fileSigned };
} else {
return {};
}
},
{ concurrency: 1 },
);
const getFileType = (file: File) => {
if (file.type) return file.type;
if (file.name.endsWith(".heic")) return "image/heic";
throw new Error("Unknown file type");
};
const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
const exif = ExifReader.load(fileBuffer);
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
if (!dateTimeOriginal) return undefined;
const [date, time] = dateTimeOriginal.split(" ");
if (!date || !time) return undefined;
const [year, month, day] = date.split(":").map(Number);
const [hour, minute, second] = time.split(":").map(Number);
if (!year || !month || !day || !hour || !minute || !second) return undefined;
if (!offsetTimeOriginal) {
// No timezone information.. Assume local timezone
return new Date(year, month - 1, day, hour, minute, second);
}
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
return new Date(utcDate - offsetMs);
};
const encryptFile = limitFunction(
async (
status: Writable<FileUploadStatus>,
file: File,
fileBuffer: ArrayBuffer,
masterKey: MasterKey,
) => {
status.update((value) => {
value.status = "encrypting";
return value;
});
const fileType = getFileType(file);
let createdAt;
if (fileType.startsWith("image/")) {
createdAt = extractExifDateTime(fileBuffer);
}
const { dataKey, dataKeyVersion } = await generateDataKey();
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
const fileEncrypted = await encryptData(fileBuffer, dataKey);
const nameEncrypted = await encryptString(file.name, dataKey);
const createdAtEncrypted =
createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
status.update((value) => {
value.status = "upload-pending";
return value;
});
return {
dataKeyWrapped,
dataKeyVersion,
fileEncrypted,
fileType,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
};
},
{ concurrency: 4 },
);
const requestFileUpload = limitFunction(
async (status: Writable<FileUploadStatus>, form: FormData) => {
status.update((value) => {
value.status = "uploading";
return value;
});
await axios.post("/api/file/upload", form, {
onUploadProgress: ({ progress, rate, estimated }) => {
status.update((value) => {
value.progress = progress;
value.rate = rate;
value.estimated = estimated;
return value;
});
},
});
status.update((value) => {
value.status = "uploaded";
return value;
});
},
{ concurrency: 1 },
);
export const uploadFile = async (
file: File,
parentId: "root" | number,
hmacSecret: HmacSecret,
masterKey: MasterKey,
onDuplicate: () => Promise<boolean>,
) => {
const status = writable<FileUploadStatus>({
name: file.name,
parentId,
status: "encryption-pending",
});
fileUploadStatusStore.update((value) => {
value.push(status);
return value;
});
try {
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
file,
hmacSecret,
onDuplicate,
);
if (!fileBuffer || !fileSigned) {
status.update((value) => {
value.status = "canceled";
return value;
});
fileUploadStatusStore.update((value) => {
value = value.filter((v) => v !== status);
return value;
});
return false;
}
const {
dataKeyWrapped,
dataKeyVersion,
fileEncrypted,
fileType,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
} = await encryptFile(status, file, fileBuffer, masterKey);
const form = new FormData();
form.set(
"metadata",
JSON.stringify({
parent: parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion.toISOString(),
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
contentType: fileType,
contentIv: fileEncrypted.iv,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
} as FileUploadRequest),
);
form.set("content", new Blob([fileEncrypted.ciphertext]));
await requestFileUpload(status, form);
return true;
} catch (e) {
status.update((value) => {
value.status = "error";
return value;
});
throw e;
}
};

View File

@@ -0,0 +1,206 @@
import { get, writable, type Writable } from "svelte/store";
import { callGetApi } from "$lib/hooks";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
type DirectoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
export type DirectoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export interface FileInfo {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
}
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const fetchDirectoryInfoFromIndexedDB = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
) => {
if (get(info)) return;
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
const subDirectoryIds = subDirectories.map(({ id }) => id);
const fileIds = files.map(({ id }) => id);
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
if (!directory) return;
info.set({ id, name: directory.name, subDirectoryIds, fileIds });
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
const res = await callGetApi(`/api/directory/${id}`);
if (res.status === 404) {
info.set(null);
await deleteDirectoryInfo(id as number);
} else if (!res.ok) {
throw new Error("Failed to fetch directory information");
}
const {
metadata,
subDirectories: subDirectoryIds,
files: fileIds,
}: DirectoryInfoResponse = await res.json();
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
info.set({
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
});
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
}
};
const fetchDirectoryInfo = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
await fetchDirectoryInfoFromIndexedDB(id, info);
await fetchDirectoryInfoFromServer(id, info, masterKey);
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = directoryInfoStore.get(id);
if (!info) {
info = writable(null);
directoryInfoStore.set(id, info);
}
fetchDirectoryInfo(id, info, masterKey);
return info;
};
const fetchFileInfoFromIndexedDB = async (id: number, info: Writable<FileInfo | null>) => {
if (get(info)) return;
const file = await getFileInfoFromIndexedDB(id);
if (!file) return;
info.set(file);
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
const fetchFileInfoFromServer = async (
id: number,
info: Writable<FileInfo | null>,
masterKey: CryptoKey,
) => {
const res = await callGetApi(`/api/file/${id}`);
if (res.status === 404) {
info.set(null);
await deleteFileInfo(id);
} else 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 name = await decryptString(metadata.name, metadata.nameIv, dataKey);
const createdAt =
metadata.createdAt && metadata.createdAtIv
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
: undefined;
const lastModifiedAt = await decryptDate(
metadata.lastModifiedAt,
metadata.lastModifiedAtIv,
dataKey,
);
info.set({
id,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
});
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
});
};
const fetchFileInfo = async (id: number, info: Writable<FileInfo | null>, masterKey: CryptoKey) => {
await fetchFileInfoFromIndexedDB(id, info);
await fetchFileInfoFromServer(id, info, masterKey);
};
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = fileInfoStore.get(fileId);
if (!info) {
info = writable(null);
fileInfoStore.set(fileId, info);
}
fetchFileInfo(fileId, info, masterKey);
return info;
};

61
src/lib/modules/opfs.ts Normal file
View File

@@ -0,0 +1,61 @@
let rootHandle: FileSystemDirectoryHandle | null = null;
export const prepareOpfs = async () => {
rootHandle = await navigator.storage.getDirectory();
};
const getFileHandle = async (path: string, create = true) => {
if (!rootHandle) {
throw new Error("OPFS not prepared");
} else if (path[0] !== "/") {
throw new Error("Path must be absolute");
}
const parts = path.split("/");
if (parts.length <= 1) {
throw new Error("Invalid path");
}
try {
let directoryHandle = rootHandle;
for (const part of parts.slice(0, -1)) {
if (!part) continue;
directoryHandle = await directoryHandle.getDirectoryHandle(part, { create });
}
const filename = parts[parts.length - 1]!;
const fileHandle = await directoryHandle.getFileHandle(filename, { create });
return { parentHandle: directoryHandle, filename, fileHandle };
} catch (e) {
if (e instanceof DOMException && e.name === "NotFoundError") {
return {};
}
throw e;
}
};
export const readFile = async (path: string) => {
const { fileHandle } = await getFileHandle(path, false);
if (!fileHandle) return null;
const file = await fileHandle.getFile();
return await file.arrayBuffer();
};
export const writeFile = async (path: string, data: ArrayBuffer) => {
const { fileHandle } = await getFileHandle(path);
const writable = await fileHandle!.createWritable();
try {
await writable.write(data);
} finally {
await writable.close();
}
};
export const deleteFile = async (path: string) => {
const { parentHandle, filename } = await getFileHandle(path, false);
if (!parentHandle) return;
await parentHandle.removeEntry(filename);
};

29
src/lib/modules/util.ts Normal file
View File

@@ -0,0 +1,29 @@
const pad2 = (num: number) => num.toString().padStart(2, "0");
export const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return `${year}. ${month}. ${day}.`;
};
export const formatDateTime = (date: Date) => {
const dateFormatted = formatDate(date);
const hours = date.getHours();
const minutes = date.getMinutes();
return `${dateFormatted} ${pad2(hours)}:${pad2(minutes)}`;
};
export const formatFileSize = (size: number) => {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`;
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MiB`;
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GiB`;
};
export const formatNetworkSpeed = (speed: number) => {
if (speed < 1000) return `${speed} bps`;
if (speed < 1000 * 1000) return `${(speed / 1000).toFixed(1)} kbps`;
if (speed < 1000 * 1000 * 1000) return `${(speed / 1000 / 1000).toFixed(1)} Mbps`;
return `${(speed / 1000 / 1000 / 1000).toFixed(1)} Gbps`;
};

View File

@@ -1,30 +1,36 @@
import { and, or, eq, gt, lte, count } from "drizzle-orm";
import { SqliteError } from "better-sqlite3";
import { and, or, eq, gt, lte } from "drizzle-orm";
import db from "./drizzle";
import { IntegrityError } from "./error";
import { client, userClient, userClientChallenge } from "./schema";
export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => {
return await db.transaction(async (tx) => {
return await db.transaction(
async (tx) => {
const clients = await tx
.select()
.select({ id: client.id })
.from(client)
.where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey)));
if (clients.length > 0) {
throw new Error("Already used public key(s)");
.where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey)))
.limit(1);
if (clients.length !== 0) {
throw new IntegrityError("Public key(s) already registered");
}
const insertRes = await tx
const newClients = await tx
.insert(client)
.values({ encPubKey, sigPubKey })
.returning({ id: client.id });
const { id: clientId } = insertRes[0]!;
const { id: clientId } = newClients[0]!;
await tx.insert(userClient).values({ userId, clientId });
return clientId;
});
},
{ behavior: "exclusive" },
);
};
export const getClient = async (clientId: number) => {
const clients = await db.select().from(client).where(eq(client.id, clientId)).execute();
const clients = await db.select().from(client).where(eq(client.id, clientId)).limit(1);
return clients[0] ?? null;
};
@@ -33,24 +39,23 @@ export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) =
.select()
.from(client)
.where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey)))
.execute();
.limit(1);
return clients[0] ?? null;
};
export const countClientByPubKey = async (pubKey: string) => {
const clients = await db
.select({ count: count() })
.from(client)
.where(or(eq(client.encPubKey, pubKey), eq(client.encPubKey, pubKey)));
return clients[0]?.count ?? 0;
};
export const createUserClient = async (userId: number, clientId: number) => {
await db.insert(userClient).values({ userId, clientId }).execute();
try {
await db.insert(userClient).values({ userId, clientId });
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
throw new IntegrityError("User client already exists");
}
throw e;
}
};
export const getAllUserClients = async (userId: number) => {
return await db.select().from(userClient).where(eq(userClient.userId, userId)).execute();
return await db.select().from(userClient).where(eq(userClient.userId, userId));
};
export const getUserClient = async (userId: number, clientId: number) => {
@@ -58,7 +63,7 @@ export const getUserClient = async (userId: number, clientId: number) => {
.select()
.from(userClient)
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
.execute();
.limit(1);
return userClients[0] ?? null;
};
@@ -68,7 +73,7 @@ export const getUserClientWithDetails = async (userId: number, clientId: number)
.from(userClient)
.innerJoin(client, eq(userClient.clientId, client.id))
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
.execute();
.limit(1);
return userClients[0] ?? null;
};
@@ -82,8 +87,7 @@ export const setUserClientStateToPending = async (userId: number, clientId: numb
eq(userClient.clientId, clientId),
eq(userClient.state, "challenging"),
),
)
.execute();
);
};
export const setUserClientStateToActive = async (userId: number, clientId: number) => {
@@ -96,8 +100,7 @@ export const setUserClientStateToActive = async (userId: number, clientId: numbe
eq(userClient.clientId, clientId),
eq(userClient.state, "pending"),
),
)
.execute();
);
};
export const registerUserClientChallenge = async (
@@ -107,45 +110,30 @@ export const registerUserClientChallenge = async (
allowedIp: string,
expiresAt: Date,
) => {
await db
.insert(userClientChallenge)
.values({
await db.insert(userClientChallenge).values({
userId,
clientId,
answer,
allowedIp,
expiresAt,
})
.execute();
});
};
export const getUserClientChallenge = async (answer: string, ip: string) => {
export const consumeUserClientChallenge = async (userId: number, answer: string, ip: string) => {
const challenges = await db
.select()
.from(userClientChallenge)
.delete(userClientChallenge)
.where(
and(
eq(userClientChallenge.userId, userId),
eq(userClientChallenge.answer, answer),
eq(userClientChallenge.allowedIp, ip),
gt(userClientChallenge.expiresAt, new Date()),
eq(userClientChallenge.isUsed, false),
),
)
.execute();
.returning({ clientId: userClientChallenge.clientId });
return challenges[0] ?? null;
};
export const markUserClientChallengeAsUsed = async (id: number) => {
await db
.update(userClientChallenge)
.set({ isUsed: true })
.where(eq(userClientChallenge.id, id))
.execute();
};
export const cleanupExpiredUserClientChallenges = async () => {
await db
.delete(userClientChallenge)
.where(lte(userClientChallenge.expiresAt, new Date()))
.execute();
await db.delete(userClientChallenge).where(lte(userClientChallenge.expiresAt, new Date()));
};

View File

@@ -0,0 +1,26 @@
type IntegrityErrorMessages =
// Challenge
| "Challenge already registered"
// Client
| "Public key(s) already registered"
| "User client already exists"
// File
| "Directory not found"
| "File not found"
| "Invalid DEK version"
// HSK
| "HSK already registered"
| "Inactive HSK version"
// MEK
| "MEK already registered"
| "Inactive MEK version"
// Session
| "Session not found"
| "Session already exists";
export class IntegrityError extends Error {
constructor(public message: IntegrityErrorMessages) {
super(message);
this.name = "IntegrityError";
}
}

View File

@@ -1,12 +1,13 @@
import { and, eq, isNull } from "drizzle-orm";
import db from "./drizzle";
import { directory, file, mek } from "./schema";
import { IntegrityError } from "./error";
import { directory, directoryLog, file, fileLog, hsk, mek } from "./schema";
type DirectoryId = "root" | number;
export interface NewDirectoryParams {
userId: number;
parentId: DirectoryId;
userId: number;
mekVersion: number;
encDek: string;
dekVersion: Date;
@@ -15,52 +16,69 @@ export interface NewDirectoryParams {
}
export interface NewFileParams {
path: string;
parentId: DirectoryId;
userId: number;
path: string;
mekVersion: number;
encDek: string;
dekVersion: Date;
hskVersion: number | null;
contentHmac: string | null;
contentType: string;
encContentIv: string;
encName: string;
encNameIv: string;
encCreatedAt: string | null;
encCreatedAtIv: string | null;
encLastModifiedAt: string;
encLastModifiedAtIv: string;
}
export const registerNewDirectory = async (params: NewDirectoryParams) => {
return await db.transaction(async (tx) => {
export const registerDirectory = async (params: NewDirectoryParams) => {
await db.transaction(
async (tx) => {
const meks = await tx
.select()
.select({ version: mek.version })
.from(mek)
.where(and(eq(mek.userId, params.userId), eq(mek.state, "active")));
.where(and(eq(mek.userId, params.userId), eq(mek.state, "active")))
.limit(1);
if (meks[0]?.version !== params.mekVersion) {
throw new Error("Invalid MEK version");
throw new IntegrityError("Inactive MEK version");
}
const now = new Date();
await tx.insert(directory).values({
createdAt: now,
const newDirectories = await tx
.insert(directory)
.values({
parentId: params.parentId === "root" ? null : params.parentId,
userId: params.userId,
mekVersion: params.mekVersion,
encDek: params.encDek,
dekVersion: params.dekVersion,
encName: { ciphertext: params.encName, iv: params.encNameIv },
})
.returning({ id: directory.id });
const { id: directoryId } = newDirectories[0]!;
await tx.insert(directoryLog).values({
directoryId,
timestamp: new Date(),
action: "create",
newName: { ciphertext: params.encName, iv: params.encNameIv },
});
});
},
{ behavior: "exclusive" },
);
};
export const getAllDirectoriesByParent = async (userId: number, directoryId: DirectoryId) => {
export const getAllDirectoriesByParent = async (userId: number, parentId: DirectoryId) => {
return await db
.select()
.from(directory)
.where(
and(
eq(directory.userId, userId),
directoryId === "root" ? isNull(directory.parentId) : eq(directory.parentId, directoryId),
parentId === "root" ? isNull(directory.parentId) : eq(directory.parentId, parentId),
),
)
.execute();
);
};
export const getDirectory = async (userId: number, directoryId: number) => {
@@ -68,7 +86,7 @@ export const getDirectory = async (userId: number, directoryId: number) => {
.select()
.from(directory)
.where(and(eq(directory.userId, userId), eq(directory.id, directoryId)))
.execute();
.limit(1);
return res[0] ?? null;
};
@@ -79,72 +97,133 @@ export const setDirectoryEncName = async (
encName: string,
encNameIv: string,
) => {
const res = await db
await db.transaction(
async (tx) => {
const directories = await tx
.select({ version: directory.dekVersion })
.from(directory)
.where(and(eq(directory.userId, userId), eq(directory.id, directoryId)))
.limit(1);
if (!directories[0]) {
throw new IntegrityError("Directory not found");
} else if (directories[0].version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
}
await tx
.update(directory)
.set({ encName: { ciphertext: encName, iv: encNameIv } })
.where(
and(
eq(directory.userId, userId),
eq(directory.id, directoryId),
eq(directory.dekVersion, dekVersion),
),
)
.execute();
return res.changes > 0;
.where(and(eq(directory.userId, userId), eq(directory.id, directoryId)));
await tx.insert(directoryLog).values({
directoryId,
timestamp: new Date(),
action: "rename",
newName: { ciphertext: encName, iv: encNameIv },
});
},
{ behavior: "exclusive" },
);
};
export const unregisterDirectory = async (userId: number, directoryId: number) => {
return await db.transaction(async (tx) => {
const getFilePaths = async (parentId: number) => {
const files = await tx
.select({ path: file.path })
.from(file)
.where(and(eq(file.userId, userId), eq(file.parentId, parentId)));
return files.map(({ path }) => path);
return await db.transaction(
async (tx) => {
const unregisterFiles = async (parentId: number) => {
return await tx
.delete(file)
.where(and(eq(file.userId, userId), eq(file.parentId, parentId)))
.returning({ id: file.id, path: file.path });
};
const unregisterSubDirectoriesRecursively = async (directoryId: number): Promise<string[]> => {
const unregisterDirectoryRecursively = async (
directoryId: number,
): Promise<{ id: number; path: string }[]> => {
const files = await unregisterFiles(directoryId);
const subDirectories = await tx
.select({ id: directory.id })
.from(directory)
.where(and(eq(directory.userId, userId), eq(directory.parentId, directoryId)));
const subDirectoryFilePaths = await Promise.all(
subDirectories.map(async ({ id }) => await unregisterSubDirectoriesRecursively(id)),
subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
);
const filePaths = await getFilePaths(directoryId);
await tx.delete(file).where(eq(file.parentId, directoryId));
await tx.delete(directory).where(eq(directory.id, directoryId));
return filePaths.concat(...subDirectoryFilePaths);
const deleteRes = await tx.delete(directory).where(eq(directory.id, directoryId));
if (deleteRes.changes === 0) {
throw new IntegrityError("Directory not found");
}
return files.concat(...subDirectoryFilePaths);
};
return await unregisterSubDirectoriesRecursively(directoryId);
});
return await unregisterDirectoryRecursively(directoryId);
},
{ behavior: "exclusive" },
);
};
export const registerNewFile = async (params: NewFileParams) => {
await db.transaction(async (tx) => {
const meks = await tx
.select()
.from(mek)
.where(and(eq(mek.userId, params.userId), eq(mek.state, "active")));
if (meks[0]?.version !== params.mekVersion) {
throw new Error("Invalid MEK version");
export const registerFile = async (params: NewFileParams) => {
if (
(params.hskVersion && !params.contentHmac) ||
(!params.hskVersion && params.contentHmac) ||
(params.encCreatedAt && !params.encCreatedAtIv) ||
(!params.encCreatedAt && params.encCreatedAtIv)
) {
throw new Error("Invalid arguments");
}
const now = new Date();
await tx.insert(file).values({
await db.transaction(
async (tx) => {
const meks = await tx
.select({ version: mek.version })
.from(mek)
.where(and(eq(mek.userId, params.userId), eq(mek.state, "active")))
.limit(1);
if (meks[0]?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
if (params.hskVersion) {
const hsks = await tx
.select({ version: hsk.version })
.from(hsk)
.where(and(eq(hsk.userId, params.userId), eq(hsk.state, "active")))
.limit(1);
if (hsks[0]?.version !== params.hskVersion) {
throw new IntegrityError("Inactive HSK version");
}
}
const newFiles = await tx
.insert(file)
.values({
path: params.path,
parentId: params.parentId === "root" ? null : params.parentId,
createdAt: now,
userId: params.userId,
mekVersion: params.mekVersion,
hskVersion: params.hskVersion,
contentHmac: params.contentHmac,
contentType: params.contentType,
encDek: params.encDek,
dekVersion: params.dekVersion,
encContentIv: params.encContentIv,
encName: { ciphertext: params.encName, iv: params.encNameIv },
encCreatedAt:
params.encCreatedAt && params.encCreatedAtIv
? { ciphertext: params.encCreatedAt, iv: params.encCreatedAtIv }
: null,
encLastModifiedAt: {
ciphertext: params.encLastModifiedAt,
iv: params.encLastModifiedAtIv,
},
})
.returning({ id: file.id });
const { id: fileId } = newFiles[0]!;
await tx.insert(fileLog).values({
fileId,
timestamp: new Date(),
action: "create",
newName: { ciphertext: params.encName, iv: params.encNameIv },
});
});
},
{ behavior: "exclusive" },
);
};
export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => {
@@ -156,8 +235,24 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectoryId)
eq(file.userId, userId),
parentId === "root" ? isNull(file.parentId) : eq(file.parentId, parentId),
),
)
.execute();
);
};
export const getAllFileIdsByContentHmac = async (
userId: number,
hskVersion: number,
contentHmac: string,
) => {
return await db
.select({ id: file.id })
.from(file)
.where(
and(
eq(file.userId, userId),
eq(file.hskVersion, hskVersion),
eq(file.contentHmac, contentHmac),
),
);
};
export const getFile = async (userId: number, fileId: number) => {
@@ -165,7 +260,7 @@ export const getFile = async (userId: number, fileId: number) => {
.select()
.from(file)
.where(and(eq(file.userId, userId), eq(file.id, fileId)))
.execute();
.limit(1);
return res[0] ?? null;
};
@@ -176,19 +271,41 @@ export const setFileEncName = async (
encName: string,
encNameIv: string,
) => {
const res = await db
await db.transaction(
async (tx) => {
const files = await tx
.select({ version: file.dekVersion })
.from(file)
.where(and(eq(file.userId, userId), eq(file.id, fileId)))
.limit(1);
if (!files[0]) {
throw new IntegrityError("File not found");
} else if (files[0].version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
}
await tx
.update(file)
.set({ encName: { ciphertext: encName, iv: encNameIv } })
.where(and(eq(file.userId, userId), eq(file.id, fileId), eq(file.dekVersion, dekVersion)))
.execute();
return res.changes > 0;
.where(and(eq(file.userId, userId), eq(file.id, fileId)));
await tx.insert(fileLog).values({
fileId,
timestamp: new Date(),
action: "rename",
newName: { ciphertext: encName, iv: encNameIv },
});
},
{ behavior: "exclusive" },
);
};
export const unregisterFile = async (userId: number, fileId: number) => {
const res = await db
const files = await db
.delete(file)
.where(and(eq(file.userId, userId), eq(file.id, fileId)))
.returning({ path: file.path })
.execute();
return res[0]?.path ?? null;
.returning({ path: file.path });
if (!files[0]) {
throw new IntegrityError("File not found");
}
return files[0].path;
};

46
src/lib/server/db/hsk.ts Normal file
View File

@@ -0,0 +1,46 @@
import { SqliteError } from "better-sqlite3";
import { and, eq } from "drizzle-orm";
import db from "./drizzle";
import { IntegrityError } from "./error";
import { hsk, hskLog } from "./schema";
export const registerInitialHsk = async (
userId: number,
createdBy: number,
mekVersion: number,
encHsk: string,
) => {
await db.transaction(
async (tx) => {
try {
await tx.insert(hsk).values({
userId,
version: 1,
state: "active",
mekVersion,
encHsk,
});
await tx.insert(hskLog).values({
userId,
hskVersion: 1,
timestamp: new Date(),
action: "create",
actionBy: createdBy,
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
throw new IntegrityError("HSK already registered");
}
throw e;
}
},
{ behavior: "exclusive" },
);
};
export const getAllValidHsks = async (userId: number) => {
return await db
.select()
.from(hsk)
.where(and(eq(hsk.userId, userId), eq(hsk.state, "active")));
};

View File

@@ -1,6 +1,8 @@
import { SqliteError } from "better-sqlite3";
import { and, or, eq } from "drizzle-orm";
import db from "./drizzle";
import { mek, clientMek } from "./schema";
import { IntegrityError } from "./error";
import { mek, mekLog, clientMek } from "./schema";
export const registerInitialMek = async (
userId: number,
@@ -8,12 +10,12 @@ export const registerInitialMek = async (
encMek: string,
encMekSig: string,
) => {
await db.transaction(async (tx) => {
await db.transaction(
async (tx) => {
try {
await tx.insert(mek).values({
userId,
version: 1,
createdBy,
createdAt: new Date(),
state: "active",
});
await tx.insert(clientMek).values({
@@ -23,7 +25,22 @@ export const registerInitialMek = async (
encMek,
encMekSig,
});
await tx.insert(mekLog).values({
userId,
mekVersion: 1,
timestamp: new Date(),
action: "create",
actionBy: createdBy,
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
throw new IntegrityError("MEK already registered");
}
throw e;
}
},
{ behavior: "exclusive" },
);
};
export const getInitialMek = async (userId: number) => {
@@ -31,19 +48,10 @@ export const getInitialMek = async (userId: number) => {
.select()
.from(mek)
.where(and(eq(mek.userId, userId), eq(mek.version, 1)))
.execute();
.limit(1);
return meks[0] ?? null;
};
export const getActiveMekVersion = async (userId: number) => {
const meks = await db
.select({ version: mek.version })
.from(mek)
.where(and(eq(mek.userId, userId), eq(mek.state, "active")))
.execute();
return meks[0]?.version ?? null;
};
export const getAllValidClientMeks = async (userId: number, clientId: number) => {
return await db
.select()
@@ -55,6 +63,5 @@ export const getAllValidClientMeks = async (userId: number, clientId: number) =>
eq(clientMek.clientId, clientId),
or(eq(mek.state, "active"), eq(mek.state, "retired")),
),
)
.execute();
);
};

View File

@@ -1,4 +1,11 @@
import { sqliteTable, text, integer, primaryKey, unique } from "drizzle-orm/sqlite-core";
import {
sqliteTable,
text,
integer,
primaryKey,
foreignKey,
unique,
} from "drizzle-orm/sqlite-core";
import { user } from "./user";
export const client = sqliteTable(
@@ -31,7 +38,9 @@ export const userClient = sqliteTable(
}),
);
export const userClientChallenge = sqliteTable("user_client_challenge", {
export const userClientChallenge = sqliteTable(
"user_client_challenge",
{
id: integer("id").primaryKey(),
userId: integer("user_id")
.notNull()
@@ -39,8 +48,14 @@ export const userClientChallenge = sqliteTable("user_client_challenge", {
clientId: integer("client_id")
.notNull()
.references(() => client.id),
answer: text("challenge").notNull().unique(), // Base64
answer: text("answer").notNull().unique(), // Base64
allowedIp: text("allowed_ip").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false),
});
},
(t) => ({
ref: foreignKey({
columns: [t.userId, t.clientId],
foreignColumns: [userClient.userId, userClient.clientId],
}),
}),
);

View File

@@ -1,4 +1,5 @@
import { sqliteTable, text, integer, foreignKey } from "drizzle-orm/sqlite-core";
import { hsk } from "./hsk";
import { mek } from "./mek";
import { user } from "./user";
@@ -12,7 +13,6 @@ export const directory = sqliteTable(
"directory",
{
id: integer("id").primaryKey({ autoIncrement: true }),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
parentId: integer("parent_id"),
userId: integer("user_id")
.notNull()
@@ -34,27 +34,54 @@ export const directory = sqliteTable(
}),
);
export const directoryLog = sqliteTable("directory_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
directoryId: integer("directory_id")
.notNull()
.references(() => directory.id, { onDelete: "cascade" }),
timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(),
action: text("action", { enum: ["create", "rename"] }).notNull(),
newName: ciphertext("new_name"),
});
export const file = sqliteTable(
"file",
{
id: integer("id").primaryKey({ autoIncrement: true }),
path: text("path").notNull().unique(),
parentId: integer("parent_id").references(() => directory.id),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
userId: integer("user_id")
.notNull()
.references(() => user.id),
path: text("path").notNull().unique(),
mekVersion: integer("master_encryption_key_version").notNull(),
encDek: text("encrypted_data_encryption_key").notNull().unique(), // Base64
dekVersion: integer("data_encryption_key_version", { mode: "timestamp_ms" }).notNull(),
hskVersion: integer("hmac_secret_key_version"),
contentHmac: text("content_hmac"), // Base64
contentType: text("content_type").notNull(),
encContentIv: text("encrypted_content_iv").notNull(), // Base64
encName: ciphertext("encrypted_name").notNull(),
encCreatedAt: ciphertext("encrypted_created_at"),
encLastModifiedAt: ciphertext("encrypted_last_modified_at").notNull(),
},
(t) => ({
ref: foreignKey({
ref1: foreignKey({
columns: [t.userId, t.mekVersion],
foreignColumns: [mek.userId, mek.version],
}),
ref2: foreignKey({
columns: [t.userId, t.hskVersion],
foreignColumns: [hsk.userId, hsk.version],
}),
}),
);
export const fileLog = sqliteTable("file_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
fileId: integer("file_id")
.notNull()
.references(() => file.id, { onDelete: "cascade" }),
timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(),
action: text("action", { enum: ["create", "rename"] }).notNull(),
newName: ciphertext("new_name"),
});

View File

@@ -0,0 +1,43 @@
import { sqliteTable, text, integer, primaryKey, foreignKey } from "drizzle-orm/sqlite-core";
import { mek } from "./mek";
import { user } from "./user";
export const hsk = sqliteTable(
"hmac_secret_key",
{
userId: integer("user_id")
.notNull()
.references(() => user.id),
version: integer("version").notNull(),
state: text("state", { enum: ["active"] }).notNull(),
mekVersion: integer("master_encryption_key_version").notNull(),
encHsk: text("encrypted_key").notNull().unique(), // Base64
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.version] }),
ref: foreignKey({
columns: [t.userId, t.mekVersion],
foreignColumns: [mek.userId, mek.version],
}),
}),
);
export const hskLog = sqliteTable(
"hmac_secret_key_log",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => user.id),
hskVersion: integer("hmac_secret_key_version").notNull(),
timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(),
action: text("action", { enum: ["create"] }).notNull(),
actionBy: integer("action_by").references(() => user.id),
},
(t) => ({
ref: foreignKey({
columns: [t.userId, t.hskVersion],
foreignColumns: [hsk.userId, hsk.version],
}),
}),
);

View File

@@ -1,5 +1,6 @@
export * from "./client";
export * from "./file";
export * from "./hsk";
export * from "./mek";
export * from "./token";
export * from "./session";
export * from "./user";

View File

@@ -9,10 +9,6 @@ export const mek = sqliteTable(
.notNull()
.references(() => user.id),
version: integer("version").notNull(),
createdBy: integer("created_by")
.notNull()
.references(() => client.id),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
state: text("state", { enum: ["active", "retired", "dead"] }).notNull(),
retiredAt: integer("retired_at", { mode: "timestamp_ms" }),
},
@@ -21,6 +17,26 @@ export const mek = sqliteTable(
}),
);
export const mekLog = sqliteTable(
"master_encryption_key_log",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => user.id),
mekVersion: integer("master_encryption_key_version").notNull(),
timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(),
action: text("action", { enum: ["create"] }).notNull(),
actionBy: integer("action_by").references(() => client.id),
},
(t) => ({
ref: foreignKey({
columns: [t.userId, t.mekVersion],
foreignColumns: [mek.userId, mek.version],
}),
}),
);
export const clientMek = sqliteTable(
"client_master_encryption_key",
{

View File

@@ -2,31 +2,34 @@ import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core";
import { client } from "./client";
import { user } from "./user";
export const refreshToken = sqliteTable(
"refresh_token",
export const session = sqliteTable(
"session",
{
id: text("id").primaryKey(),
id: text("id").notNull().primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => user.id),
clientId: integer("client_id").references(() => client.id),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), // Only used for cleanup
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
lastUsedAt: integer("last_used_at", { mode: "timestamp_ms" }).notNull(),
lastUsedByIp: text("last_used_by_ip"),
lastUsedByUserAgent: text("last_used_by_user_agent"),
},
(t) => ({
unq: unique().on(t.userId, t.clientId),
}),
);
export const tokenUpgradeChallenge = sqliteTable("token_upgrade_challenge", {
export const sessionUpgradeChallenge = sqliteTable("session_upgrade_challenge", {
id: integer("id").primaryKey(),
refreshTokenId: text("refresh_token_id")
sessionId: text("session_id")
.notNull()
.references(() => refreshToken.id),
.references(() => session.id)
.unique(),
clientId: integer("client_id")
.notNull()
.references(() => client.id),
answer: text("challenge").notNull().unique(), // Base64
answer: text("answer").notNull().unique(), // Base64
allowedIp: text("allowed_ip").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false),
});

View File

@@ -4,4 +4,5 @@ export const user = sqliteTable("user", {
id: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull().unique(),
password: text("password").notNull(),
nickname: text("nickname").notNull(),
});

View File

@@ -0,0 +1,128 @@
import { SqliteError } from "better-sqlite3";
import { and, eq, ne, gt, lte, isNull } from "drizzle-orm";
import env from "$lib/server/loadenv";
import db from "./drizzle";
import { IntegrityError } from "./error";
import { session, sessionUpgradeChallenge } from "./schema";
export const createSession = async (
userId: number,
clientId: number | null,
sessionId: string,
ip: string | null,
userAgent: string | null,
) => {
try {
const now = new Date();
await db.insert(session).values({
id: sessionId,
userId,
clientId,
createdAt: now,
lastUsedAt: now,
lastUsedByIp: ip || null,
lastUsedByUserAgent: userAgent || null,
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new IntegrityError("Session already exists");
}
throw e;
}
};
export const refreshSession = async (
sessionId: string,
ip: string | null,
userAgent: string | null,
) => {
const now = new Date();
const sessions = await db
.update(session)
.set({
lastUsedAt: now,
lastUsedByIp: ip || undefined,
lastUsedByUserAgent: userAgent || undefined,
})
.where(
and(
eq(session.id, sessionId),
gt(session.lastUsedAt, new Date(now.getTime() - env.session.exp)),
),
)
.returning({ userId: session.userId, clientId: session.clientId });
if (!sessions[0]) {
throw new IntegrityError("Session not found");
}
return sessions[0];
};
export const upgradeSession = async (sessionId: string, clientId: number) => {
const res = await db
.update(session)
.set({ clientId })
.where(and(eq(session.id, sessionId), isNull(session.clientId)));
if (res.changes === 0) {
throw new IntegrityError("Session not found");
}
};
export const deleteSession = async (sessionId: string) => {
await db.delete(session).where(eq(session.id, sessionId));
};
export const deleteAllOtherSessions = async (userId: number, sessionId: string) => {
await db.delete(session).where(and(eq(session.userId, userId), ne(session.id, sessionId)));
};
export const cleanupExpiredSessions = async () => {
await db.delete(session).where(lte(session.lastUsedAt, new Date(Date.now() - env.session.exp)));
};
export const registerSessionUpgradeChallenge = async (
sessionId: string,
clientId: number,
answer: string,
allowedIp: string,
expiresAt: Date,
) => {
try {
await db.insert(sessionUpgradeChallenge).values({
sessionId,
clientId,
answer,
allowedIp,
expiresAt,
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new IntegrityError("Challenge already registered");
}
throw e;
}
};
export const consumeSessionUpgradeChallenge = async (
sessionId: string,
answer: string,
ip: string,
) => {
const challenges = await db
.delete(sessionUpgradeChallenge)
.where(
and(
eq(sessionUpgradeChallenge.sessionId, sessionId),
eq(sessionUpgradeChallenge.answer, answer),
eq(sessionUpgradeChallenge.allowedIp, ip),
gt(sessionUpgradeChallenge.expiresAt, new Date()),
),
)
.returning({ clientId: sessionUpgradeChallenge.clientId });
return challenges[0] ?? null;
};
export const cleanupExpiredSessionUpgradeChallenges = async () => {
await db
.delete(sessionUpgradeChallenge)
.where(lte(sessionUpgradeChallenge.expiresAt, new Date()));
};

View File

@@ -1,133 +0,0 @@
import { SqliteError } from "better-sqlite3";
import { and, eq, gt, lte } from "drizzle-orm";
import env from "$lib/server/loadenv";
import db from "./drizzle";
import { refreshToken, tokenUpgradeChallenge } from "./schema";
const expiresAt = () => new Date(Date.now() + env.jwt.refreshExp);
export const registerRefreshToken = async (
userId: number,
clientId: number | null,
tokenId: string,
) => {
try {
await db
.insert(refreshToken)
.values({
id: tokenId,
userId,
clientId,
expiresAt: expiresAt(),
})
.execute();
return true;
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
return false;
}
throw e;
}
};
export const getRefreshToken = async (tokenId: string) => {
const tokens = await db.select().from(refreshToken).where(eq(refreshToken.id, tokenId)).execute();
return tokens[0] ?? null;
};
export const rotateRefreshToken = async (oldTokenId: string, newTokenId: string) => {
return await db.transaction(async (tx) => {
await tx
.delete(tokenUpgradeChallenge)
.where(eq(tokenUpgradeChallenge.refreshTokenId, oldTokenId));
const res = await db
.update(refreshToken)
.set({
id: newTokenId,
expiresAt: expiresAt(),
})
.where(eq(refreshToken.id, oldTokenId))
.execute();
return res.changes > 0;
});
};
export const upgradeRefreshToken = async (
oldTokenId: string,
newTokenId: string,
clientId: number,
) => {
return await db.transaction(async (tx) => {
await tx
.delete(tokenUpgradeChallenge)
.where(eq(tokenUpgradeChallenge.refreshTokenId, oldTokenId));
const res = await tx
.update(refreshToken)
.set({
id: newTokenId,
clientId,
expiresAt: expiresAt(),
})
.where(eq(refreshToken.id, oldTokenId))
.execute();
return res.changes > 0;
});
};
export const revokeRefreshToken = async (tokenId: string) => {
await db.delete(refreshToken).where(eq(refreshToken.id, tokenId)).execute();
};
export const cleanupExpiredRefreshTokens = async () => {
await db.delete(refreshToken).where(lte(refreshToken.expiresAt, new Date())).execute();
};
export const registerTokenUpgradeChallenge = async (
tokenId: string,
clientId: number,
answer: string,
allowedIp: string,
expiresAt: Date,
) => {
await db
.insert(tokenUpgradeChallenge)
.values({
refreshTokenId: tokenId,
clientId,
answer,
allowedIp,
expiresAt,
})
.execute();
};
export const getTokenUpgradeChallenge = async (answer: string, ip: string) => {
const challenges = await db
.select()
.from(tokenUpgradeChallenge)
.where(
and(
eq(tokenUpgradeChallenge.answer, answer),
eq(tokenUpgradeChallenge.allowedIp, ip),
gt(tokenUpgradeChallenge.expiresAt, new Date()),
eq(tokenUpgradeChallenge.isUsed, false),
),
)
.execute();
return challenges[0] ?? null;
};
export const markTokenUpgradeChallengeAsUsed = async (id: number) => {
await db
.update(tokenUpgradeChallenge)
.set({ isUsed: true })
.where(eq(tokenUpgradeChallenge.id, id))
.execute();
};
export const cleanupExpiredTokenUpgradeChallenges = async () => {
await db
.delete(tokenUpgradeChallenge)
.where(lte(tokenUpgradeChallenge.expiresAt, new Date()))
.execute();
};

View File

@@ -2,7 +2,20 @@ import { eq } from "drizzle-orm";
import db from "./drizzle";
import { user } from "./schema";
export const getUserByEmail = async (email: string) => {
const users = await db.select().from(user).where(eq(user.email, email)).execute();
export const getUser = async (userId: number) => {
const users = await db.select().from(user).where(eq(user.id, userId)).limit(1);
return users[0] ?? null;
};
export const getUserByEmail = async (email: string) => {
const users = await db.select().from(user).where(eq(user.email, email)).limit(1);
return users[0] ?? null;
};
export const setUserPassword = async (userId: number, password: string) => {
await db.update(user).set({ password }).where(eq(user.id, userId));
};
export const setUserNickname = async (userId: number, nickname: string) => {
await db.update(user).set({ nickname }).where(eq(user.id, userId));
};

View File

@@ -3,19 +3,18 @@ import { building } from "$app/environment";
import { env } from "$env/dynamic/private";
if (!building) {
if (!env.JWT_SECRET) throw new Error("JWT_SECRET is not set");
if (!env.SESSION_SECRET) throw new Error("SESSION_SECRET not set");
}
export default {
databaseUrl: env.DATABASE_URL || "local.db",
jwt: {
secret: env.JWT_SECRET,
accessExp: ms(env.JWT_ACCESS_TOKEN_EXPIRES || "5m"),
refreshExp: ms(env.JWT_REFRESH_TOKEN_EXPIRES || "14d"),
session: {
secret: env.SESSION_SECRET!,
exp: ms(env.SESSION_EXPIRES || "14d"),
},
challenge: {
userClientExp: ms(env.USER_CLIENT_CHALLENGE_EXPIRES || "5m"),
tokenUpgradeExp: ms(env.TOKEN_UPGRADE_CHALLENGE_EXPIRES || "5m"),
sessionUpgradeExp: ms(env.SESSION_UPGRADE_CHALLENGE_EXPIRES || "5m"),
},
libraryPath: env.LIBRARY_PATH || "library",
};

View File

@@ -0,0 +1,34 @@
import { error, redirect, type Handle } from "@sveltejs/kit";
import { authenticate, AuthenticationError } from "$lib/server/modules/auth";
export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
const { pathname, search } = event.url;
if (pathname === "/api/auth/login") {
return await resolve(event);
}
try {
const sessionIdSigned = event.cookies.get("sessionId");
if (!sessionIdSigned) {
throw new AuthenticationError(401, "Session id not found");
}
const { ip, userAgent } = event.locals;
event.locals.session = await authenticate(sessionIdSigned, ip, userAgent);
} catch (e) {
if (e instanceof AuthenticationError) {
if (pathname === "/auth/login") {
return await resolve(event);
} else if (pathname.startsWith("/api")) {
error(e.status, e.message);
} else {
redirect(302, "/auth/login?redirect=" + encodeURIComponent(pathname + search));
}
}
throw e;
}
return await resolve(event);
};
export default authenticateMiddleware;

View File

@@ -0,0 +1,2 @@
export { default as authenticate } from "./authenticate";
export { default as setAgentInfo } from "./setAgentInfo";

View File

@@ -0,0 +1,18 @@
import { error, type Handle } from "@sveltejs/kit";
export const setAgentInfoMiddleware: Handle = async ({ event, resolve }) => {
const ip = event.getClientAddress();
const userAgent = event.request.headers.get("User-Agent");
if (!ip) {
error(500, "IP address not found");
} else if (!userAgent && !event.isSubRequest) {
error(400, "User agent not found");
}
event.locals.ip = ip;
event.locals.userAgent = userAgent ?? "";
return await resolve(event);
};
export default setAgentInfoMiddleware;

View File

@@ -1,90 +1,127 @@
import { error, type Cookies } from "@sveltejs/kit";
import jwt from "jsonwebtoken";
import { error } from "@sveltejs/kit";
import { getUserClient } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { createSession, refreshSession } from "$lib/server/db/session";
import env from "$lib/server/loadenv";
import { issueSessionId, verifySessionId } from "$lib/server/modules/crypto";
type TokenPayload =
| {
type: "access";
interface Session {
sessionId: string;
userId: number;
clientId?: number;
}
| {
type: "refresh";
jti: string;
};
export enum TokenError {
EXPIRED,
INVALID,
interface ClientSession extends Session {
clientId: number;
}
type Permission = "pendingClient" | "activeClient";
export class AuthenticationError extends Error {
constructor(
public status: 400 | 401,
message: string,
) {
super(message);
this.name = "AuthenticationError";
}
}
export const issueToken = (payload: TokenPayload) => {
return jwt.sign(payload, env.jwt.secret, {
expiresIn: (payload.type === "access" ? env.jwt.accessExp : env.jwt.refreshExp) / 1000,
});
export const startSession = async (userId: number, ip: string, userAgent: string) => {
const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret);
await createSession(userId, null, sessionId, ip, userAgent);
return sessionIdSigned;
};
export const verifyToken = (token: string) => {
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");
}
try {
return jwt.verify(token, env.jwt.secret) as TokenPayload;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return TokenError.EXPIRED;
}
return TokenError.INVALID;
}
};
export const authenticate = (cookies: Cookies) => {
const accessToken = cookies.get("accessToken");
if (!accessToken) {
error(401, "Access token not found");
}
const tokenPayload = verifyToken(accessToken);
if (tokenPayload === TokenError.EXPIRED) {
error(401, "Access token expired");
} else if (tokenPayload === TokenError.INVALID || tokenPayload.type !== "access") {
error(401, "Invalid access token");
}
const { userId, clientId } = await refreshSession(sessionId, ip, userAgent);
return {
userId: tokenPayload.userId,
clientId: tokenPayload.clientId,
id: sessionId,
userId,
clientId: clientId ?? undefined,
};
} catch (e) {
if (e instanceof IntegrityError && e.message === "Session not found") {
throw new AuthenticationError(401, "Invalid session id");
}
throw e;
}
};
export async function authorize(locals: App.Locals, requiredPermission: "any"): Promise<Session>;
export async function authorize(
cookies: Cookies,
locals: App.Locals,
requiredPermission: "notClient",
): Promise<Session>;
export async function authorize(
locals: App.Locals,
requiredPermission: "anyClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "pendingClient",
): Promise<{ userId: number; clientId: number }>;
): Promise<ClientSession>;
export async function authorize(
cookies: Cookies,
locals: App.Locals,
requiredPermission: "activeClient",
): Promise<{ userId: number; clientId: number }>;
): Promise<ClientSession>;
export async function authorize(
cookies: Cookies,
requiredPermission: Permission,
): Promise<{ userId: number; clientId?: number }> {
const tokenPayload = authenticate(cookies);
const { userId, clientId } = tokenPayload;
const userClient = clientId ? await getUserClient(userId, clientId) : undefined;
locals: App.Locals,
requiredPermission: "any" | "notClient" | "anyClient" | "pendingClient" | "activeClient",
): Promise<Session> {
if (!locals.session) {
error(500, "Unauthenticated");
}
const { id: sessionId, userId, clientId } = locals.session;
switch (requiredPermission) {
case "pendingClient":
if (!userClient || userClient.state !== "pending") {
case "any":
break;
case "notClient":
if (clientId) {
error(403, "Forbidden");
}
return tokenPayload;
case "activeClient":
if (!userClient || userClient.state !== "active") {
break;
case "anyClient":
if (!clientId) {
error(403, "Forbidden");
}
return tokenPayload;
break;
case "pendingClient": {
if (!clientId) {
error(403, "Forbidden");
}
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
} else if (userClient.state !== "pending") {
error(403, "Forbidden");
}
break;
}
case "activeClient": {
if (!clientId) {
error(403, "Forbidden");
}
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
} else if (userClient.state !== "active") {
error(403, "Forbidden");
}
break;
}
}
return { sessionId, userId, clientId };
}

View File

@@ -1,4 +1,12 @@
import { constants, randomBytes, createPublicKey, publicEncrypt, verify } from "crypto";
import {
constants,
randomBytes,
createPublicKey,
publicEncrypt,
verify,
createHmac,
timingSafeEqual,
} from "crypto";
import { promisify } from "util";
const makePubKeyToPem = (pubKey: string) =>
@@ -34,3 +42,26 @@ export const generateChallenge = async (length: number, encPubKey: string) => {
const challenge = encryptAsymmetric(answer, encPubKey);
return { answer, challenge };
};
export const issueSessionId = async (length: number, secret: string) => {
const sessionId = await promisify(randomBytes)(length);
const sessionIdHex = sessionId.toString("hex");
const sessionIdHmac = createHmac("sha256", secret).update(sessionId).digest("hex");
return {
sessionId: sessionIdHex,
sessionIdSigned: `${sessionIdHex}.${sessionIdHmac}`,
};
};
export const verifySessionId = (sessionIdSigned: string, secret: string) => {
const [sessionIdHex, sessionIdHmac] = sessionIdSigned.split(".");
if (!sessionIdHex || !sessionIdHmac) return;
if (
timingSafeEqual(
Buffer.from(sessionIdHmac, "hex"),
createHmac("sha256", secret).update(Buffer.from(sessionIdHex, "hex")).digest(),
)
) {
return sessionIdHex;
}
};

View File

@@ -17,7 +17,7 @@ export const verifyClientEncMekSig = async (
) => {
const userClient = await getUserClientWithDetails(userId, clientId);
if (!userClient) {
error(500, "Invalid access token");
error(500, "Invalid session id");
}
const data = JSON.stringify({ version, key: encMek });

View File

@@ -1,24 +1,30 @@
import { z } from "zod";
export const passwordChangeRequest = z.object({
oldPassword: z.string().trim().nonempty(),
newPassword: z.string().trim().nonempty(),
});
export type PasswordChangeRequest = z.infer<typeof passwordChangeRequest>;
export const loginRequest = z.object({
email: z.string().email().nonempty(),
password: z.string().trim().nonempty(),
});
export type LoginRequest = z.infer<typeof loginRequest>;
export const tokenUpgradeRequest = z.object({
export const sessionUpgradeRequest = z.object({
encPubKey: z.string().base64().nonempty(),
sigPubKey: z.string().base64().nonempty(),
});
export type TokenUpgradeRequest = z.infer<typeof tokenUpgradeRequest>;
export type SessionUpgradeRequest = z.infer<typeof sessionUpgradeRequest>;
export const tokenUpgradeResponse = z.object({
export const sessionUpgradeResponse = z.object({
challenge: z.string().base64().nonempty(),
});
export type TokenUpgradeResponse = z.infer<typeof tokenUpgradeResponse>;
export type SessionUpgradeResponse = z.infer<typeof sessionUpgradeResponse>;
export const tokenUpgradeVerifyRequest = z.object({
export const sessionUpgradeVerifyRequest = z.object({
answer: z.string().base64().nonempty(),
answerSig: z.string().base64().nonempty(),
});
export type TokenUpgradeVerifyRequest = z.infer<typeof tokenUpgradeVerifyRequest>;
export type SessionUpgradeVerifyRequest = z.infer<typeof sessionUpgradeVerifyRequest>;

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
export const directoryInfoResponse = z.object({
metadata: z
.object({
createdAt: z.string().datetime(),
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
@@ -16,6 +16,11 @@ export const directoryInfoResponse = z.object({
});
export type DirectoryInfoResponse = z.infer<typeof directoryInfoResponse>;
export const directoryDeleteResponse = z.object({
deletedFiles: z.number().int().positive().array(),
});
export type DirectoryDeleteResponse = z.infer<typeof directoryDeleteResponse>;
export const directoryRenameRequest = z.object({
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
@@ -24,7 +29,7 @@ export const directoryRenameRequest = z.object({
export type DirectoryRenameRequest = z.infer<typeof directoryRenameRequest>;
export const directoryCreateRequest = z.object({
parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),

View File

@@ -2,7 +2,7 @@ import mime from "mime";
import { z } from "zod";
export const fileInfoResponse = z.object({
createdAt: z.string().datetime(),
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
@@ -13,6 +13,10 @@ export const fileInfoResponse = z.object({
contentIv: z.string().base64().nonempty(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
createdAt: z.string().base64().nonempty().optional(),
createdAtIv: z.string().base64().nonempty().optional(),
lastModifiedAt: z.string().base64().nonempty(),
lastModifiedAtIv: z.string().base64().nonempty(),
});
export type FileInfoResponse = z.infer<typeof fileInfoResponse>;
@@ -23,11 +27,24 @@ export const fileRenameRequest = z.object({
});
export type FileRenameRequest = z.infer<typeof fileRenameRequest>;
export const duplicateFileScanRequest = z.object({
hskVersion: z.number().int().positive(),
contentHmac: z.string().base64().nonempty(),
});
export type DuplicateFileScanRequest = z.infer<typeof duplicateFileScanRequest>;
export const duplicateFileScanResponse = z.object({
files: z.number().int().positive().array(),
});
export type DuplicateFileScanResponse = z.infer<typeof duplicateFileScanResponse>;
export const fileUploadRequest = z.object({
parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
hskVersion: z.number().int().positive(),
contentHmac: z.string().base64().nonempty(),
contentType: z
.string()
.nonempty()
@@ -35,5 +52,9 @@ export const fileUploadRequest = z.object({
contentIv: z.string().base64().nonempty(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
createdAt: z.string().base64().nonempty().optional(),
createdAtIv: z.string().base64().nonempty().optional(),
lastModifiedAt: z.string().base64().nonempty(),
lastModifiedAtIv: z.string().base64().nonempty(),
});
export type FileUploadRequest = z.infer<typeof fileUploadRequest>;

View File

@@ -0,0 +1,19 @@
import { z } from "zod";
export const hmacSecretListResponse = z.object({
hsks: z.array(
z.object({
version: z.number().int().positive(),
state: z.enum(["active"]),
mekVersion: z.number().int().positive(),
hsk: z.string().base64().nonempty(),
}),
),
});
export type HmacSecretListResponse = z.infer<typeof hmacSecretListResponse>;
export const initialHmacSecretRegisterRequest = z.object({
mekVersion: z.number().int().positive(),
hsk: z.string().base64().nonempty(),
});
export type InitialHmacSecretRegisterRequest = z.infer<typeof initialHmacSecretRegisterRequest>;

View File

@@ -2,4 +2,6 @@ export * from "./auth";
export * from "./client";
export * from "./directory";
export * from "./file";
export * from "./hsk";
export * from "./mek";
export * from "./user";

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export const userInfoResponse = z.object({
email: z.string().email().nonempty(),
nickname: z.string().nonempty(),
});
export type UserInfoResponse = z.infer<typeof userInfoResponse>;
export const nicknameChangeRequest = z.object({
newNickname: z.string().min(2).max(8),
});
export type NicknameChangeRequest = z.infer<typeof nicknameChangeRequest>;

View File

@@ -1,164 +1,121 @@
import { error } from "@sveltejs/kit";
import argon2 from "argon2";
import { v4 as uuidv4 } from "uuid";
import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client";
import { getUserByEmail } from "$lib/server/db/user";
import env from "$lib/server/loadenv";
import { IntegrityError } from "$lib/server/db/error";
import {
getRefreshToken,
registerRefreshToken,
rotateRefreshToken,
upgradeRefreshToken,
revokeRefreshToken,
registerTokenUpgradeChallenge,
getTokenUpgradeChallenge,
markTokenUpgradeChallengeAsUsed,
} from "$lib/server/db/token";
import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth";
upgradeSession,
deleteSession,
deleteAllOtherSessions,
registerSessionUpgradeChallenge,
consumeSessionUpgradeChallenge,
} from "$lib/server/db/session";
import { getUser, getUserByEmail, setUserPassword } from "$lib/server/db/user";
import env from "$lib/server/loadenv";
import { startSession } from "$lib/server/modules/auth";
import { verifySignature, generateChallenge } from "$lib/server/modules/crypto";
const hashPassword = async (password: string) => {
return await argon2.hash(password);
};
const verifyPassword = async (hash: string, password: string) => {
return await argon2.verify(hash, password);
};
const issueAccessToken = (userId: number, clientId?: number) => {
return issueToken({ type: "access", userId, clientId });
};
const issueRefreshToken = async (userId: number, clientId?: number) => {
const jti = uuidv4();
const token = issueToken({ type: "refresh", jti });
if (!(await registerRefreshToken(userId, clientId ?? null, jti))) {
error(403, "Already logged in");
export const changePassword = async (
userId: number,
sessionId: string,
oldPassword: string,
newPassword: string,
) => {
if (oldPassword === newPassword) {
error(400, "Same passwords");
} else if (newPassword.length < 8) {
error(400, "Too short password");
}
return token;
const user = await getUser(userId);
if (!user) {
error(500, "Invalid session id");
} else if (!(await verifyPassword(user.password, oldPassword))) {
error(403, "Invalid password");
}
await setUserPassword(userId, await hashPassword(newPassword));
await deleteAllOtherSessions(userId, sessionId);
};
export const login = async (email: string, password: string) => {
export const login = async (email: string, password: string, ip: string, userAgent: string) => {
const user = await getUserByEmail(email);
if (!user || !(await verifyPassword(user.password, password))) {
error(401, "Invalid email or password");
}
return {
accessToken: issueAccessToken(user.id),
refreshToken: await issueRefreshToken(user.id),
};
};
const verifyRefreshToken = async (refreshToken: string) => {
const tokenPayload = verifyToken(refreshToken);
if (tokenPayload === TokenError.EXPIRED) {
error(401, "Refresh token expired");
} else if (tokenPayload === TokenError.INVALID || tokenPayload.type !== "refresh") {
error(401, "Invalid refresh token");
try {
return { sessionIdSigned: await startSession(user.id, ip, userAgent) };
} catch (e) {
if (e instanceof IntegrityError && e.message === "Session already exists") {
error(403, "Already logged in");
}
const tokenData = await getRefreshToken(tokenPayload.jti);
if (!tokenData) {
error(500, "Refresh token not found");
throw e;
}
return {
jti: tokenPayload.jti,
userId: tokenData.userId,
clientId: tokenData.clientId ?? undefined,
};
};
export const logout = async (refreshToken: string) => {
const { jti } = await verifyRefreshToken(refreshToken);
await revokeRefreshToken(jti);
export const logout = async (sessionId: string) => {
await deleteSession(sessionId);
};
export const refreshToken = async (refreshToken: string) => {
const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken);
const newJti = uuidv4();
if (!(await rotateRefreshToken(oldJti, newJti))) {
error(500, "Refresh token not found");
}
return {
accessToken: issueAccessToken(userId, clientId),
refreshToken: issueToken({ type: "refresh", jti: newJti }),
};
};
const expiresAt = () => new Date(Date.now() + env.challenge.tokenUpgradeExp);
const createChallenge = async (
ip: string,
tokenId: string,
clientId: number,
encPubKey: string,
) => {
const { answer, challenge } = await generateChallenge(32, encPubKey);
await registerTokenUpgradeChallenge(
tokenId,
clientId,
answer.toString("base64"),
ip,
expiresAt(),
);
return challenge.toString("base64");
};
export const createTokenUpgradeChallenge = async (
refreshToken: string,
export const createSessionUpgradeChallenge = async (
sessionId: string,
userId: number,
ip: string,
encPubKey: string,
sigPubKey: string,
) => {
const { jti, userId, clientId } = await verifyRefreshToken(refreshToken);
if (clientId) {
error(403, "Forbidden");
}
const client = await getClientByPubKeys(encPubKey, sigPubKey);
const userClient = client ? await getUserClient(userId, client.id) : undefined;
if (!client) {
error(401, "Invalid public key(s)");
} else if (!userClient || userClient.state === "challenging") {
error(401, "Unregistered client");
error(403, "Unregistered client");
}
return { challenge: await createChallenge(ip, jti, client.id, encPubKey) };
const { answer, challenge } = await generateChallenge(32, encPubKey);
await registerSessionUpgradeChallenge(
sessionId,
client.id,
answer.toString("base64"),
ip,
new Date(Date.now() + env.challenge.sessionUpgradeExp),
);
return { challenge: challenge.toString("base64") };
};
export const upgradeToken = async (
refreshToken: string,
export const verifySessionUpgradeChallenge = async (
sessionId: string,
ip: string,
answer: string,
answerSig: string,
) => {
const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken);
if (clientId) {
error(403, "Forbidden");
}
const challenge = await getTokenUpgradeChallenge(answer, ip);
const challenge = await consumeSessionUpgradeChallenge(sessionId, answer, ip);
if (!challenge) {
error(401, "Invalid challenge answer");
} else if (challenge.refreshTokenId !== oldJti) {
error(403, "Forbidden");
error(403, "Invalid challenge answer");
}
const client = await getClient(challenge.clientId);
if (!client) {
error(500, "Invalid challenge answer");
} else if (!verifySignature(Buffer.from(answer, "base64"), answerSig, client.sigPubKey)) {
error(401, "Invalid challenge answer signature");
error(403, "Invalid challenge answer signature");
}
await markTokenUpgradeChallengeAsUsed(challenge.id);
const newJti = uuidv4();
if (!(await upgradeRefreshToken(oldJti, newJti, client.id))) {
error(500, "Refresh token not found");
try {
await upgradeSession(sessionId, client.id);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Session not found") {
error(500, "Invalid challenge answer");
}
throw e;
}
return {
accessToken: issueAccessToken(userId, client.id),
refreshToken: issueToken({ type: "refresh", jti: newJti }),
};
};

View File

@@ -3,15 +3,14 @@ import {
createClient,
getClient,
getClientByPubKeys,
countClientByPubKey,
createUserClient,
getAllUserClients,
getUserClient,
setUserClientStateToPending,
registerUserClientChallenge,
getUserClientChallenge,
markUserClientChallengeAsUsed,
consumeUserClientChallenge,
} from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto";
import { isInitialMekNeeded } from "$lib/server/modules/mek";
import env from "$lib/server/loadenv";
@@ -29,8 +28,8 @@ export const getUserClientList = async (userId: number) => {
const expiresAt = () => new Date(Date.now() + env.challenge.userClientExp);
const createUserClientChallenge = async (
userId: number,
ip: string,
userId: number,
clientId: number,
encPubKey: string,
) => {
@@ -45,45 +44,34 @@ export const registerUserClient = async (
encPubKey: string,
sigPubKey: string,
) => {
let clientId;
const client = await getClientByPubKeys(encPubKey, sigPubKey);
if (client) {
const userClient = await getUserClient(userId, client.id);
if (userClient) {
try {
await createUserClient(userId, client.id);
return { challenge: await createUserClientChallenge(ip, userId, client.id, encPubKey) };
} catch (e) {
if (e instanceof IntegrityError && e.message === "User client already exists") {
error(409, "Client already registered");
}
await createUserClient(userId, client.id);
clientId = client.id;
throw e;
}
} else {
if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) {
if (encPubKey === sigPubKey) {
error(400, "Same public keys");
} else if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) {
error(400, "Invalid public key(s)");
} else if (encPubKey === sigPubKey) {
error(400, "Public keys must be different");
} else if (
(await countClientByPubKey(encPubKey)) > 0 ||
(await countClientByPubKey(sigPubKey)) > 0
) {
error(409, "Public key(s) already registered");
}
clientId = await createClient(encPubKey, sigPubKey, userId);
try {
const clientId = await createClient(encPubKey, sigPubKey, userId);
return { challenge: await createUserClientChallenge(ip, userId, clientId, encPubKey) };
} catch (e) {
if (e instanceof IntegrityError && e.message === "Public key(s) already registered") {
error(409, "Public key(s) already used");
}
throw e;
}
return { challenge: await createUserClientChallenge(userId, ip, clientId, encPubKey) };
};
export const getUserClientStatus = async (userId: number, clientId: number) => {
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid access token");
}
return {
state: userClient.state as "pending" | "active",
isInitialMekNeeded: await isInitialMekNeeded(userId),
};
};
export const verifyUserClient = async (
@@ -92,20 +80,29 @@ export const verifyUserClient = async (
answer: string,
answerSig: string,
) => {
const challenge = await getUserClientChallenge(answer, ip);
const challenge = await consumeUserClientChallenge(userId, answer, ip);
if (!challenge) {
error(401, "Invalid challenge answer");
} else if (challenge.userId !== userId) {
error(403, "Forbidden");
error(403, "Invalid challenge answer");
}
const client = await getClient(challenge.clientId);
if (!client) {
error(500, "Invalid challenge answer");
} else if (!verifySignature(Buffer.from(answer, "base64"), answerSig, client.sigPubKey)) {
error(401, "Invalid challenge answer signature");
error(403, "Invalid challenge answer signature");
}
await markUserClientChallengeAsUsed(challenge.id);
await setUserClientStateToPending(userId, challenge.clientId);
await setUserClientStateToPending(userId, client.id);
};
export const getUserClientStatus = async (userId: number, clientId: number) => {
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
}
return {
state: userClient.state as "pending" | "active",
isInitialMekNeeded: await isInitialMekNeeded(userId),
};
};

View File

@@ -1,44 +1,15 @@
import { error } from "@sveltejs/kit";
import { unlink } from "fs/promises";
import { IntegrityError } from "$lib/server/db/error";
import {
registerDirectory,
getAllDirectoriesByParent,
registerNewDirectory,
getDirectory,
setDirectoryEncName,
unregisterDirectory,
getAllFilesByParent,
type NewDirectoryParams,
} from "$lib/server/db/file";
import { getActiveMekVersion } from "$lib/server/db/mek";
export const deleteDirectory = async (userId: number, directoryId: number) => {
const directory = await getDirectory(userId, directoryId);
if (!directory) {
error(404, "Invalid directory id");
}
const filePaths = await unregisterDirectory(userId, directoryId);
filePaths.map((path) => unlink(path)); // Intended
};
export const renameDirectory = async (
userId: number,
directoryId: number,
dekVersion: Date,
newEncName: string,
newEncNameIv: string,
) => {
const directory = await getDirectory(userId, directoryId);
if (!directory) {
error(404, "Invalid directory id");
} else if (directory.dekVersion.getTime() !== dekVersion.getTime()) {
error(400, "Invalid DEK version");
}
if (!(await setDirectoryEncName(userId, directoryId, dekVersion, newEncName, newEncNameIv))) {
error(500, "Invalid directory id or DEK version");
}
};
export const getDirectoryInformation = async (userId: number, directoryId: "root" | number) => {
const directory = directoryId !== "root" ? await getDirectory(userId, directoryId) : undefined;
@@ -48,10 +19,9 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
const directories = await getAllDirectoriesByParent(userId, directoryId);
const files = await getAllFilesByParent(userId, directoryId);
return {
metadata: directory && {
createdAt: directory.createdAt,
parentId: directory.parentId ?? ("root" as const),
mekVersion: directory.mekVersion,
encDek: directory.encDek,
dekVersion: directory.dekVersion,
@@ -62,19 +32,57 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
};
};
export const createDirectory = async (params: NewDirectoryParams) => {
const activeMekVersion = await getActiveMekVersion(params.userId);
if (activeMekVersion === null) {
error(500, "Invalid MEK version");
} else if (activeMekVersion !== params.mekVersion) {
error(400, "Invalid MEK version");
export const deleteDirectory = async (userId: number, directoryId: number) => {
try {
const files = await unregisterDirectory(userId, directoryId);
return {
files: files.map(({ id, path }) => {
unlink(path); // Intended
return id;
}),
};
} catch (e) {
if (e instanceof IntegrityError && e.message === "Directory not found") {
error(404, "Invalid directory id");
}
throw e;
}
};
export const renameDirectory = async (
userId: number,
directoryId: number,
dekVersion: Date,
newEncName: string,
newEncNameIv: string,
) => {
try {
await setDirectoryEncName(userId, directoryId, dekVersion, newEncName, newEncNameIv);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Directory not found") {
error(404, "Invalid directory id");
} else if (e.message === "Invalid DEK version") {
error(400, "Invalid DEK version");
}
}
throw e;
}
};
export const createDirectory = async (params: NewDirectoryParams) => {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
error(400, "Invalid DEK version");
}
await registerNewDirectory(params);
try {
await registerDirectory(params);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Invalid MEK version");
}
throw e;
}
};

View File

@@ -1,43 +1,50 @@
import { error } from "@sveltejs/kit";
import { createReadStream, createWriteStream, ReadStream, WriteStream } from "fs";
import { createReadStream, createWriteStream } from "fs";
import { mkdir, stat, unlink } from "fs/promises";
import { dirname } from "path";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import { v4 as uuidv4 } from "uuid";
import { IntegrityError } from "$lib/server/db/error";
import {
registerNewFile,
registerFile,
getAllFileIdsByContentHmac,
getFile,
setFileEncName,
unregisterFile,
type NewFileParams,
} from "$lib/server/db/file";
import { getActiveMekVersion } from "$lib/server/db/mek";
import env from "$lib/server/loadenv";
export const deleteFile = async (userId: number, fileId: number) => {
export const getFileInformation = async (userId: number, fileId: number) => {
const file = await getFile(userId, fileId);
if (!file) {
error(404, "Invalid file id");
}
const path = await unregisterFile(userId, fileId);
if (!path) {
error(500, "Invalid file id");
}
unlink(path); // Intended
return {
parentId: file.parentId ?? ("root" as const),
mekVersion: file.mekVersion,
encDek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
encContentIv: file.encContentIv,
encName: file.encName,
encCreatedAt: file.encCreatedAt,
encLastModifiedAt: file.encLastModifiedAt,
};
};
const convertToReadableStream = (readStream: ReadStream) => {
return new ReadableStream<Uint8Array>({
start: (controller) => {
readStream.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk as Buffer)));
readStream.on("end", () => controller.close());
readStream.on("error", (e) => controller.error(e));
},
cancel: () => {
readStream.destroy();
},
});
export const deleteFile = async (userId: number, fileId: number) => {
try {
const filePath = await unregisterFile(userId, fileId);
unlink(filePath); // Intended
} catch (e) {
if (e instanceof IntegrityError && e.message === "File not found") {
error(404, "Invalid file id");
}
throw e;
}
};
export const getFileStream = async (userId: number, fileId: number) => {
@@ -48,7 +55,7 @@ export const getFileStream = async (userId: number, fileId: number) => {
const { size } = await stat(file.path);
return {
encContentStream: convertToReadableStream(createReadStream(file.path)),
encContentStream: Readable.toWeb(createReadStream(file.path)),
encContentSize: size,
};
};
@@ -60,49 +67,27 @@ export const renameFile = async (
newEncName: string,
newEncNameIv: string,
) => {
const file = await getFile(userId, fileId);
if (!file) {
try {
await setFileEncName(userId, fileId, dekVersion, newEncName, newEncNameIv);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "File not found") {
error(404, "Invalid file id");
} else if (file.dekVersion.getTime() !== dekVersion.getTime()) {
} else if (e.message === "Invalid DEK version") {
error(400, "Invalid DEK version");
}
if (!(await setFileEncName(userId, fileId, dekVersion, newEncName, newEncNameIv))) {
error(500, "Invalid file id or DEK version");
}
throw e;
}
};
export const getFileInformation = async (userId: number, fileId: number) => {
const file = await getFile(userId, fileId);
if (!file) {
error(404, "Invalid file id");
}
return {
createdAt: file.createdAt,
mekVersion: file.mekVersion,
encDek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
encContentIv: file.encContentIv,
encName: file.encName,
};
};
const convertToWritableStream = (writeStream: WriteStream) => {
return new WritableStream<Uint8Array>({
write: (chunk) =>
new Promise((resolve, reject) => {
writeStream.write(chunk, (e) => {
if (e) {
reject(e);
} else {
resolve();
}
});
}),
close: () => new Promise((resolve) => writeStream.end(resolve)),
});
export const scanDuplicateFiles = async (
userId: number,
hskVersion: number,
contentHmac: string,
) => {
const fileIds = await getAllFileIdsByContentHmac(userId, hskVersion, contentHmac);
return { files: fileIds.map(({ id }) => id) };
};
const safeUnlink = async (path: string) => {
@@ -111,18 +96,11 @@ const safeUnlink = async (path: string) => {
export const uploadFile = async (
params: Omit<NewFileParams, "path">,
encContentStream: ReadableStream<Uint8Array>,
encContentStream: Readable,
) => {
const activeMekVersion = await getActiveMekVersion(params.userId);
if (activeMekVersion === null) {
error(500, "Invalid MEK version");
} else if (activeMekVersion !== params.mekVersion) {
error(400, "Invalid MEK version");
}
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
if (params.dekVersion <= oneDayAgo || params.dekVersion >= oneMinuteLater) {
error(400, "Invalid DEK version");
}
@@ -130,15 +108,17 @@ export const uploadFile = async (
await mkdir(dirname(path), { recursive: true });
try {
await encContentStream.pipeTo(
convertToWritableStream(createWriteStream(path, { flags: "wx", mode: 0o600 })),
);
await registerNewFile({
await pipeline(encContentStream, createWriteStream(path, { flags: "wx", mode: 0o600 }));
await registerFile({
...params,
path,
});
} catch (e) {
await safeUnlink(path);
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Invalid MEK version");
}
throw e;
}
};

View File

@@ -0,0 +1,31 @@
import { error } from "@sveltejs/kit";
import { IntegrityError } from "$lib/server/db/error";
import { registerInitialHsk, getAllValidHsks } from "$lib/server/db/hsk";
export const getHskList = async (userId: number) => {
const hsks = await getAllValidHsks(userId);
return {
encHsks: hsks.map(({ version, state, mekVersion, encHsk }) => ({
version,
state,
mekVersion,
encHsk,
})),
};
};
export const registerInitialActiveHsk = async (
userId: number,
createdBy: number,
mekVersion: number,
encHsk: string,
) => {
try {
await registerInitialHsk(userId, createdBy, mekVersion, encHsk);
} catch (e) {
if (e instanceof IntegrityError && e.message === "HSK already registered") {
error(409, "Initial HSK already registered");
}
throw e;
}
};

View File

@@ -1,7 +1,8 @@
import { error } from "@sveltejs/kit";
import { setUserClientStateToActive } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { registerInitialMek, getAllValidClientMeks } from "$lib/server/db/mek";
import { isInitialMekNeeded, verifyClientEncMekSig } from "$lib/server/modules/mek";
import { verifyClientEncMekSig } from "$lib/server/modules/mek";
export const getClientMekList = async (userId: number, clientId: number) => {
const clientMeks = await getAllValidClientMeks(userId, clientId);
@@ -21,12 +22,17 @@ export const registerInitialActiveMek = async (
encMek: string,
encMekSig: string,
) => {
if (!(await isInitialMekNeeded(userId))) {
error(409, "Initial MEK already registered");
} else if (!(await verifyClientEncMekSig(userId, createdBy, 1, encMek, encMekSig))) {
if (!(await verifyClientEncMekSig(userId, createdBy, 1, encMek, encMekSig))) {
error(400, "Invalid signature");
}
try {
await registerInitialMek(userId, createdBy, encMek, encMekSig);
await setUserClientStateToActive(userId, createdBy);
} catch (e) {
if (e instanceof IntegrityError && e.message === "MEK already registered") {
error(409, "Initial MEK already registered");
}
throw e;
}
};

View File

@@ -0,0 +1,15 @@
import { error } from "@sveltejs/kit";
import { getUser, setUserNickname } from "$lib/server/db/user";
export const getUserInformation = async (userId: number) => {
const user = await getUser(userId);
if (!user) {
error(500, "Invalid session id");
}
return { email: user.email, nickname: user.nickname };
};
export const changeNickname = async (userId: number, nickname: string) => {
await setUserNickname(userId, nickname);
};

View File

@@ -1,37 +1,30 @@
import { encodeToBase64, decryptChallenge, signMessage } from "$lib/modules/crypto";
import { callPostApi } from "$lib/hooks";
import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto";
import type {
TokenUpgradeRequest,
TokenUpgradeResponse,
TokenUpgradeVerifyRequest,
SessionUpgradeRequest,
SessionUpgradeResponse,
SessionUpgradeVerifyRequest,
} from "$lib/server/schemas";
export const requestTokenUpgrade = async (
export const requestSessionUpgrade = async (
encryptKeyBase64: string,
decryptKey: CryptoKey,
verifyKeyBase64: string,
signKey: CryptoKey,
) => {
let res = await fetch("/api/auth/upgradeToken", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
let res = await callPostApi<SessionUpgradeRequest>("/api/auth/upgradeSession", {
encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64,
} satisfies TokenUpgradeRequest),
});
if (!res.ok) return false;
const { challenge }: TokenUpgradeResponse = await res.json();
const { challenge }: SessionUpgradeResponse = await res.json();
const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessage(answer, signKey);
const answerSig = await signMessageRSA(answer, signKey);
res = await fetch("/api/auth/upgradeToken/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
res = await callPostApi<SessionUpgradeVerifyRequest>("/api/auth/upgradeSession/verify", {
answer: encodeToBase64(answer),
answerSig: encodeToBase64(answerSig),
} satisfies TokenUpgradeVerifyRequest),
});
return res.ok;
};

View File

@@ -3,7 +3,7 @@ import { storeMasterKeys } from "$lib/indexedDB";
import {
encodeToBase64,
decryptChallenge,
signMessage,
signMessageRSA,
unwrapMasterKey,
verifyMasterKeyWrapped,
} from "$lib/modules/crypto";
@@ -29,7 +29,7 @@ export const requestClientRegistration = async (
const { challenge }: ClientRegisterResponse = await res.json();
const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessage(answer, signKey);
const answerSig = await signMessageRSA(answer, signKey);
res = await callPostApi<ClientRegisterVerifyRequest>("/api/client/register/verify", {
answer: encodeToBase64(answer),

View File

@@ -1,31 +1,49 @@
import type { Writable } from "svelte/store";
import { writable, type Writable } from "svelte/store";
export type DirectoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
export interface FileUploadStatus {
name: string;
subDirectoryIds: number[];
fileIds: number[];
parentId: "root" | number;
status:
| "encryption-pending"
| "encrypting"
| "upload-pending"
| "uploading"
| "uploaded"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
}
export interface FileDownloadStatus {
id: number;
status:
| "download-pending"
| "downloading"
| "decryption-pending"
| "decrypting"
| "decrypted"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
result?: ArrayBuffer;
}
export const fileUploadStatusStore = writable<Writable<FileUploadStatus>[]>([]);
export const fileDownloadStatusStore = writable<Writable<FileDownloadStatus>[]>([]);
export const isFileUploading = (
status: FileUploadStatus["status"],
): status is "encryption-pending" | "encrypting" | "upload-pending" | "uploading" => {
return ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
};
export interface FileInfo {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
contentType: string;
contentIv: string;
name: string;
}
export const directoryInfoStore = new Map<"root" | number, Writable<DirectoryInfo | null>>();
export const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
export const isFileDownloading = (
status: FileDownloadStatus["status"],
): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => {
return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
};

View File

@@ -13,6 +13,14 @@ export interface MasterKey {
key: CryptoKey;
}
export interface HmacSecret {
version: number;
state: "active";
secret: CryptoKey;
}
export const clientKeyStore = writable<ClientKeys | null>(null);
export const masterKeyStore = writable<Map<number, MasterKey> | null>(null);
export const hmacSecretStore = writable<Map<number, HmacSecret> | null>(null);

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components";
import { Button } from "$lib/components/buttons";
import { TitleDiv, BottomDiv } from "$lib/components/divs";
import { TextInput } from "$lib/components/inputs";
import { requestPasswordChange } from "./service";
let oldPassword = $state("");
let newPassword = $state("");
const changePassword = async () => {
if (await requestPasswordChange(oldPassword, newPassword)) {
await goto("/menu");
}
};
</script>
<svelte:head>
<title>비밀번호 바꾸기</title>
</svelte:head>
<div>
<TopBar />
<TitleDiv topPadding={false}>
<div class="space-y-2 break-keep">
<p class="text-2xl font-bold">기존 비밀번호와 새 비밀번호를 입력해 주세요.</p>
<p>새 비밀번호는 8자 이상이어야 해요. 다른 사람들이 알 수 없도록 안전하게 설정해 주세요.</p>
</div>
<div class="my-4 flex flex-col gap-y-2">
<TextInput bind:value={oldPassword} placeholder="기존 비밀번호" type="password" />
<TextInput bind:value={newPassword} placeholder="새 비밀번호" type="password" />
</div>
</TitleDiv>
</div>
<BottomDiv>
<Button onclick={changePassword}>비밀번호 바꾸기</Button>
</BottomDiv>

View File

@@ -0,0 +1,10 @@
import { callPostApi } from "$lib/hooks";
import type { PasswordChangeRequest } from "$lib/server/schemas";
export const requestPasswordChange = async (oldPassword: string, newPassword: string) => {
const res = await callPostApi<PasswordChangeRequest>("/api/auth/changePassword", {
oldPassword,
newPassword,
});
return res.ok;
};

View File

@@ -1,11 +1,10 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ url, cookies }) => {
export const load: PageServerLoad = async ({ locals, url }) => {
const redirectPath = url.searchParams.get("redirect") || "/home";
const accessToken = cookies.get("accessToken");
if (accessToken) {
if (locals.session) {
redirect(302, redirectPath);
}

View File

@@ -1,12 +1,10 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { Button, TextButton } from "$lib/components/buttons";
import { TitleDiv, BottomDiv } from "$lib/components/divs";
import { TextInput } from "$lib/components/inputs";
import { refreshToken } from "$lib/hooks";
import { clientKeyStore, masterKeyStore } from "$lib/stores";
import { requestLogin, requestTokenUpgrade, requestMasterKeyDownload } from "./service";
import { requestLogin, requestSessionUpgrade, requestMasterKeyDownload } from "./service";
let { data } = $props();
@@ -25,7 +23,8 @@
if (!$clientKeyStore) return await redirect("/key/generate");
if (!(await requestTokenUpgrade($clientKeyStore))) throw new Error("Failed to upgrade token");
if (!(await requestSessionUpgrade($clientKeyStore)))
throw new Error("Failed to upgrade session");
// TODO: Multi-user support
@@ -42,13 +41,6 @@
throw e;
}
};
onMount(async () => {
const res = await refreshToken();
if (res.ok) {
await goto(data.redirectPath, { replaceState: true });
}
});
</script>
<svelte:head>

View File

@@ -1,21 +1,18 @@
import { callPostApi } from "$lib/hooks";
import { exportRSAKeyToBase64 } from "$lib/modules/crypto";
import type { LoginRequest } from "$lib/server/schemas";
import { requestTokenUpgrade as requestTokenUpgradeInternal } from "$lib/services/auth";
import { requestSessionUpgrade as requestSessionUpgradeInternal } from "$lib/services/auth";
import { requestClientRegistration } from "$lib/services/key";
import type { ClientKeys } from "$lib/stores";
export { requestMasterKeyDownload } from "$lib/services/key";
export const requestLogin = async (email: string, password: string) => {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password } satisfies LoginRequest),
});
const res = await callPostApi<LoginRequest>("/api/auth/login", { email, password });
return res.ok;
};
export const requestTokenUpgrade = async ({
export const requestSessionUpgrade = async ({
encryptKey,
decryptKey,
signKey,
@@ -23,12 +20,12 @@ export const requestTokenUpgrade = async ({
}: ClientKeys) => {
const encryptKeyBase64 = await exportRSAKeyToBase64(encryptKey);
const verifyKeyBase64 = await exportRSAKeyToBase64(verifyKey);
if (await requestTokenUpgradeInternal(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) {
if (await requestSessionUpgradeInternal(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) {
return true;
}
if (await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) {
return await requestTokenUpgradeInternal(
return await requestSessionUpgradeInternal(
encryptKeyBase64,
decryptKey,
verifyKeyBase64,

View File

@@ -1,66 +1,78 @@
<script lang="ts">
import FileSaver from "file-saver";
import heic2any from "heic2any";
import { untrack } from "svelte";
import type { Writable } from "svelte/store";
import { get, type Writable } from "svelte/store";
import { TopBar } from "$lib/components";
import { getFileInfo } from "$lib/modules/file";
import { masterKeyStore, type FileInfo } from "$lib/stores";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
import DownloadStatus from "./DownloadStatus.svelte";
import { requestFileDownload } from "./service";
type ContentType = "image" | "video";
let { data } = $props();
let info: Writable<FileInfo | null> | undefined = $state();
let isDownloaded = $state(false);
let content: Blob | undefined = $state();
let contentUrl: string | undefined = $state();
let contentType: ContentType | undefined = $state();
const downloadStatus = $derived(
$fileDownloadStatusStore.find((statusStore) => {
const { id, status } = get(statusStore);
return id === data.id && isFileDownloading(status);
}),
);
let isDownloadRequested = $state(false);
let viewerType: "image" | "video" | undefined = $state();
let fileBlobUrl: string | undefined = $state();
const updateViewer = async (info: FileInfo, buffer: ArrayBuffer) => {
const contentType = info.contentType;
if (contentType.startsWith("image")) {
viewerType = "image";
} else if (contentType.startsWith("video")) {
viewerType = "video";
}
const fileBlob = new Blob([buffer], { type: contentType });
if (contentType === "image/heic") {
const { default: heic2any } = await import("heic2any");
fileBlobUrl = URL.createObjectURL(
(await heic2any({ blob: fileBlob, toType: "image/jpeg" })) as Blob,
);
} else if (viewerType) {
fileBlobUrl = URL.createObjectURL(fileBlob);
}
return fileBlob;
};
$effect(() => {
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
isDownloaded = false;
content = undefined;
contentType = undefined;
isDownloadRequested = false;
viewerType = undefined;
});
$effect(() => {
if ($info && !isDownloaded) {
if ($info && $info.dataKey && $info.contentIv) {
untrack(() => {
isDownloaded = true;
if ($info.contentType.startsWith("image")) {
contentType = "image";
} else if ($info.contentType.startsWith("video")) {
contentType = "video";
}
requestFileDownload(data.id, $info.contentIv, $info.dataKey).then(async (res) => {
content = new Blob([res], { type: $info.contentType });
if (content.type === "image/heic" || content.type === "image/heif") {
contentUrl = URL.createObjectURL(
(await heic2any({ blob: content, toType: "image/jpeg" })) as Blob,
);
} else if (contentType) {
contentUrl = URL.createObjectURL(content);
} else {
FileSaver.saveAs(content, $info.name);
if (!downloadStatus && !isDownloadRequested) {
isDownloadRequested = true;
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => {
const blob = await updateViewer($info, buffer);
if (!viewerType) {
FileSaver.saveAs(blob, $info.name);
}
});
}
});
}
});
$effect(() => {
return () => {
if (contentUrl) {
URL.revokeObjectURL(contentUrl);
if ($info && $downloadStatus?.status === "decrypted") {
untrack(() => !isDownloadRequested && updateViewer($info, $downloadStatus.result!));
}
};
});
$effect(() => () => fileBlobUrl && URL.revokeObjectURL(fileBlobUrl));
</script>
<svelte:head>
@@ -69,23 +81,24 @@
<div class="flex h-full flex-col">
<TopBar title={$info?.name} />
<div class="mb-4 flex w-full flex-grow flex-col items-center">
<DownloadStatus status={downloadStatus} />
<div class="flex w-full flex-grow flex-col items-center pb-4">
{#snippet viewerLoading(message: string)}
<div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">{message}</p>
</div>
{/snippet}
{#if $info && contentType === "image"}
{#if contentUrl}
<img src={contentUrl} alt={$info.name} />
{#if $info && viewerType === "image"}
{#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.name} />
{:else}
{@render viewerLoading("이미지를 불러오고 있어요.")}
{/if}
{:else if contentType === "video"}
{#if contentUrl}
{:else if viewerType === "video"}
{#if fileBlobUrl}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={contentUrl} controls></video>
<video src={fileBlobUrl} controls></video>
{:else}
{@render viewerLoading("비디오를 불러오고 있어요.")}
{/if}

View File

@@ -2,8 +2,6 @@ import { error } from "@sveltejs/kit";
import { z } from "zod";
import type { PageLoad } from "./$types";
export const ssr = false; // Because of heic2any
export const load: PageLoad = async ({ params }) => {
const zodRes = z
.object({

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import { isFileDownloading, type FileDownloadStatus } from "$lib/stores";
interface Props {
status?: Writable<FileDownloadStatus>;
}
let { status }: Props = $props();
</script>
{#if $status && isFileDownloading($status.status)}
<div class="w-full rounded-xl bg-gray-100 p-3">
<p class="font-medium">
{#if $status.status === "download-pending"}
다운로드를 기다리는 중
{:else if $status.status === "downloading"}
다운로드하는 중
{:else if $status.status === "decryption-pending"}
복호화를 기다리는 중
{:else if $status.status === "decrypting"}
복호화하는 중
{/if}
</p>
<p class="text-xs">
{#if $status.status === "downloading"}
전송됨
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
{/if}
</p>
</div>
{/if}

View File

@@ -1,31 +1,14 @@
import { decryptData } from "$lib/modules/crypto";
import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file";
export const requestFileDownload = (
export const requestFileDownload = async (
fileId: number,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
return new Promise<ArrayBuffer>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.responseType = "arraybuffer";
const cache = await getFileCache(fileId);
if (cache) return cache;
xhr.addEventListener("load", async () => {
if (xhr.status !== 200) {
reject(new Error("Failed to download file"));
return;
}
const fileDecrypted = await decryptData(
xhr.response as ArrayBuffer,
fileEncryptedIv,
dataKey,
);
resolve(fileDecrypted);
});
// TODO: Progress, ...
xhr.open("GET", `/api/file/${fileId}/download`);
xhr.send();
});
const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey);
storeFileCache(fileId, fileBuffer); // Intended
return fileBuffer;
};

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { get } from "svelte/store";
import { TopBar } from "$lib/components";
import { fileDownloadStatusStore, isFileDownloading } from "$lib/stores";
import File from "./File.svelte";
const downloadingFiles = $derived(
$fileDownloadStatusStore.filter((status) => isFileDownloading(get(status).status)),
);
$effect(() => () => {
$fileDownloadStatusStore = $fileDownloadStatusStore.filter((status) =>
isFileDownloading(get(status).status),
);
});
</script>
<svelte:head>
<title>진행 중인 다운로드</title>
</svelte:head>
<div class="flex h-full flex-col">
<TopBar />
<div class="space-y-2">
{#each downloadingFiles as status}
<File {status} />
{/each}
</div>
</div>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import { get, type Writable } from "svelte/store";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { formatNetworkSpeed } from "$lib/modules/util";
import { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
import IconCloud from "~icons/material-symbols/cloud";
import IconCloudDownload from "~icons/material-symbols/cloud-download";
import IconLock from "~icons/material-symbols/lock";
import IconLockClock from "~icons/material-symbols/lock-clock";
import IconCheckCircle from "~icons/material-symbols/check-circle";
import IconError from "~icons/material-symbols/error";
interface Props {
status: Writable<FileDownloadStatus>;
}
let { status }: Props = $props();
let fileInfo: Writable<FileInfo | null> | undefined = $state();
$effect(() => {
fileInfo = getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!);
});
</script>
{#if $fileInfo}
<div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg text-gray-600">
{#if $status.status === "download-pending"}
<IconCloud />
{:else if $status.status === "downloading"}
<IconCloudDownload />
{:else if $status.status === "decryption-pending"}
<IconLock />
{:else if $status.status === "decrypting"}
<IconLockClock />
{:else if $status.status === "decrypted"}
<IconCheckCircle class="text-green-500" />
{:else if $status.status === "error"}
<IconError class="text-red-500" />
{/if}
</div>
<div class="flex-grow overflow-hidden">
<p title={$fileInfo.name} class="truncate font-medium">
{$fileInfo.name}
</p>
<p class="text-xs text-gray-800">
{#if $status.status === "download-pending"}
다운로드를 기다리는 중
{:else if $status.status === "downloading"}
전송됨
{Math.floor(($status.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
{:else if $status.status === "decryption-pending"}
복호화를 기다리는 중
{:else if $status.status === "decrypting"}
복호화하는 중
{:else if $status.status === "decrypted"}
다운로드 완료
{:else if $status.status === "error"}
다운로드 실패
{/if}
</p>
</div>
</div>
{/if}

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { get } from "svelte/store";
import { TopBar } from "$lib/components";
import { fileUploadStatusStore, isFileUploading } from "$lib/stores";
import File from "./File.svelte";
const uploadingFiles = $derived(
$fileUploadStatusStore.filter((status) => isFileUploading(get(status).status)),
);
$effect(() => () => {
$fileUploadStatusStore = $fileUploadStatusStore.filter((status) =>
isFileUploading(get(status).status),
);
});
</script>
<svelte:head>
<title>진행 중인 업로드</title>
</svelte:head>
<div class="flex h-full flex-col">
<TopBar />
<div class="space-y-2">
{#each uploadingFiles as status}
<File {status} />
{/each}
</div>
</div>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import type { FileUploadStatus } from "$lib/stores";
import IconPending from "~icons/material-symbols/pending";
import IconLockClock from "~icons/material-symbols/lock-clock";
import IconCloud from "~icons/material-symbols/cloud";
import IconCloudUpload from "~icons/material-symbols/cloud-upload";
import IconCloudDone from "~icons/material-symbols/cloud-done";
import IconError from "~icons/material-symbols/error";
interface Props {
status: Writable<FileUploadStatus>;
}
let { status }: Props = $props();
</script>
<div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg text-gray-600">
{#if $status.status === "encryption-pending"}
<IconPending />
{:else if $status.status === "encrypting"}
<IconLockClock />
{:else if $status.status === "upload-pending"}
<IconCloud />
{:else if $status.status === "uploading"}
<IconCloudUpload />
{:else if $status.status === "uploaded"}
<IconCloudDone class="text-blue-500" />
{:else if $status.status === "error"}
<IconError class="text-red-500" />
{/if}
</div>
<div class="flex-grow overflow-hidden">
<p title={$status.name} class="truncate font-medium">
{$status.name}
</p>
<p class="text-xs text-gray-800">
{#if $status.status === "encryption-pending"}
준비 중
{:else if $status.status === "encrypting"}
암호화하는 중
{:else if $status.status === "upload-pending"}
업로드를 기다리는 중
{:else if $status.status === "uploading"}
전송됨
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
{:else if $status.status === "uploaded"}
업로드 완료
{:else if $status.status === "error"}
업로드 실패
{/if}
</p>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { saveAs } from "file-saver";
import FileSaver from "file-saver";
import { goto } from "$app/navigation";
import { Button, TextButton } from "$lib/components/buttons";
import { TitleDiv, BottomDiv } from "$lib/components/divs";
@@ -10,8 +10,8 @@
serializeClientKeys,
requestClientRegistration,
storeClientKeys,
requestTokenUpgrade,
requestInitialMasterKeyRegistration,
requestSessionUpgrade,
requestInitialMasterKeyAndHmacSecretRegistration,
} from "./service";
import IconKey from "~icons/material-symbols/key";
@@ -31,7 +31,7 @@
const clientKeysBlob = new Blob([JSON.stringify(clientKeysSerialized)], {
type: "application/json",
});
saveAs(clientKeysBlob, "arkvault-clientkey.json");
FileSaver.saveAs(clientKeysBlob, "arkvault-clientkey.json");
if (!isBeforeContinueBottomSheetOpen) {
setTimeout(() => {
@@ -59,19 +59,23 @@
await storeClientKeys($clientKeyStore);
if (
!(await requestTokenUpgrade(
!(await requestSessionUpgrade(
data.encryptKeyBase64,
$clientKeyStore.decryptKey,
data.verifyKeyBase64,
$clientKeyStore.signKey,
))
)
throw new Error("Failed to upgrade token");
throw new Error("Failed to upgrade session");
if (
!(await requestInitialMasterKeyRegistration(data.masterKeyWrapped, $clientKeyStore.signKey))
!(await requestInitialMasterKeyAndHmacSecretRegistration(
data.masterKeyWrapped,
data.hmacSecretWrapped,
$clientKeyStore.signKey,
))
)
throw new Error("Failed to register initial MEK");
throw new Error("Failed to register initial MEK and HSK");
await goto("/client/pending?redirect=" + encodeURIComponent(data.redirectPath));
} catch (e) {

View File

@@ -1,10 +1,13 @@
import { callPostApi } from "$lib/hooks";
import { storeClientKey } from "$lib/indexedDB";
import { signMasterKeyWrapped } from "$lib/modules/crypto";
import type { InitialMasterKeyRegisterRequest } from "$lib/server/schemas";
import type {
InitialMasterKeyRegisterRequest,
InitialHmacSecretRegisterRequest,
} from "$lib/server/schemas";
import type { ClientKeys } from "$lib/stores";
export { requestTokenUpgrade } from "$lib/services/auth";
export { requestSessionUpgrade } from "$lib/services/auth";
export { requestClientRegistration } from "$lib/services/key";
type SerializedKeyPairs = {
@@ -44,13 +47,22 @@ export const storeClientKeys = async (clientKeys: ClientKeys) => {
]);
};
export const requestInitialMasterKeyRegistration = async (
export const requestInitialMasterKeyAndHmacSecretRegistration = async (
masterKeyWrapped: string,
hmacSecretWrapped: string,
signKey: CryptoKey,
) => {
const res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", {
let res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", {
mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
});
return res.ok || res.status === 409;
if (!res.ok) {
return res.status === 409;
}
res = await callPostApi<InitialHmacSecretRegisterRequest>("/api/hsk/register/initial", {
mekVersion: 1,
hsk: hmacSecretWrapped,
});
return res.ok;
};

View File

@@ -6,7 +6,11 @@
import { gotoStateful } from "$lib/hooks";
import { clientKeyStore } from "$lib/stores";
import Order from "./Order.svelte";
import { generateClientKeys, generateInitialMasterKey } from "./service";
import {
generateClientKeys,
generateInitialMasterKey,
generateInitialHmacSecret,
} from "./service";
import IconKey from "~icons/material-symbols/key";
@@ -36,12 +40,14 @@
// TODO: Loading indicator
const { encryptKey, ...clientKeys } = await generateClientKeys();
const { masterKeyWrapped } = await generateInitialMasterKey(encryptKey);
const { masterKey, masterKeyWrapped } = await generateInitialMasterKey(encryptKey);
const { hmacSecretWrapped } = await generateInitialHmacSecret(masterKey);
await gotoStateful("/key/export", {
...clientKeys,
redirectPath: data.redirectPath,
masterKeyWrapped,
hmacSecretWrapped,
});
};

View File

@@ -3,8 +3,11 @@ import {
generateSigningKeyPair,
exportRSAKeyToBase64,
makeRSAKeyNonextractable,
generateMasterKey,
wrapMasterKey,
generateMasterKey,
makeAESKeyNonextractable,
wrapHmacSecret,
generateHmacSecret,
} from "$lib/modules/crypto";
import { clientKeyStore } from "$lib/stores";
@@ -31,6 +34,14 @@ export const generateClientKeys = async () => {
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),
};
};

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { TopBar } from "$lib/components";
import type { FileCacheIndex } from "$lib/indexedDB";
import { getFileCacheIndex } from "$lib/modules/file";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { formatFileSize } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
import { deleteFileCache as doDeleteFileCache } from "./service";
interface FileCache {
index: FileCacheIndex;
fileInfo: Writable<FileInfo | null>;
}
let fileCache: FileCache[] | undefined = $state();
let fileCacheTotalSize = $state(0);
const deleteFileCache = async (fileId: number) => {
await doDeleteFileCache(fileId);
fileCache = fileCache?.filter(({ index }) => index.fileId !== fileId);
};
onMount(() => {
fileCache = getFileCacheIndex()
.map((index) => ({
index,
fileInfo: getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!),
}))
.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime());
});
$effect(() => {
if (fileCache) {
fileCacheTotalSize = fileCache.reduce((acc, { index }) => acc + index.size, 0);
}
});
</script>
<svelte:head>
<title>캐시 설정</title>
</svelte:head>
<div class="flex h-full flex-col">
<TopBar title="캐시" />
{#if fileCache && fileCache.length > 0}
<div class="space-y-4 pb-4">
<div class="space-y-1 break-keep text-gray-800">
<p>
{fileCache.length}개 파일이 캐시되어 {formatFileSize(fileCacheTotalSize)}를 사용하고
있어요.
</p>
<p>캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.</p>
</div>
<div class="space-y-2">
{#each fileCache as { index, fileInfo }}
<File {index} info={fileInfo} onDeleteClick={deleteFileCache} />
{/each}
</div>
</div>
{:else}
<div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">
{#if fileCache}
캐시된 파일이 없어요.
{:else}
캐시 목록을 불러오고 있어요.
{/if}
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileCacheIndex } from "$lib/indexedDB";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatFileSize } from "$lib/modules/util";
import IconDraft from "~icons/material-symbols/draft";
import IconScanDelete from "~icons/material-symbols/scan-delete";
import IconDelete from "~icons/material-symbols/delete";
interface Props {
index: FileCacheIndex;
info: Writable<FileInfo | null>;
onDeleteClick: (fileId: number) => void;
}
let { index, info, onDeleteClick }: Props = $props();
</script>
<div class="flex h-14 items-center gap-x-4 p-2">
{#if $info}
<div class="flex-shrink-0 rounded-full bg-blue-100 p-1 text-xl">
<IconDraft class="text-blue-400" />
</div>
{:else}
<div class="flex-shrink-0 rounded-full bg-red-100 p-1 text-xl">
<IconScanDelete class="text-red-400" />
</div>
{/if}
<div class="flex-grow overflow-hidden">
{#if $info}
<p title={$info.name} class="truncate font-medium">{$info.name}</p>
{:else}
<p class="font-medium">삭제된 파일</p>
{/if}
<p class="text-xs text-gray-800">
읽음 {formatDate(index.lastRetrievedAt)} · {formatFileSize(index.size)}
</p>
</div>
<button
onclick={() => setTimeout(() => onDeleteClick(index.fileId), 100)}
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
>
<IconDelete class="text-lg text-gray-600" />
</button>
</div>

View File

@@ -0,0 +1,5 @@
import { deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
export const deleteFileCache = async (fileId: number) => {
await doDeleteFileCache(fileId);
};

View File

@@ -1,17 +1,22 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components";
import { FloatingButton } from "$lib/components/buttons";
import { getDirectoryInfo } from "$lib/modules/file";
import { masterKeyStore, type DirectoryInfo } from "$lib/stores";
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import CreateBottomSheet from "./CreateBottomSheet.svelte";
import CreateDirectoryModal from "./CreateDirectoryModal.svelte";
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";
import DirectoryEntries from "./DirectoryEntries";
import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte";
import DownloadStatusCard from "./DownloadStatusCard.svelte";
import DuplicateFileModal from "./DuplicateFileModal.svelte";
import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte";
import UploadStatusCard from "./UploadStatusCard.svelte";
import {
requestHmacSecretDownload,
requestDirectoryCreation,
requestFileUpload,
requestDirectoryEntryRename,
@@ -25,10 +30,13 @@
let info: Writable<DirectoryInfo | null> | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state();
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
let duplicatedFile: File | undefined = $state();
let selectedEntry: SelectedDirectoryEntry | undefined = $state();
let isCreateBottomSheetOpen = $state(false);
let isCreateDirectoryModalOpen = $state(false);
let isDuplicateFileModalOpen = $state(false);
let isDirectoryEntryMenuBottomSheetOpen = $state(false);
let isRenameDirectoryEntryModalOpen = $state(false);
@@ -41,14 +49,40 @@
};
const uploadFile = () => {
const file = fileInput?.files?.[0];
if (!file) return;
const files = fileInput?.files;
if (!files || files.length === 0) return;
requestFileUpload(file, data.id, $masterKeyStore?.get(1)!).then(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
for (const file of files) {
requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => {
return new Promise((resolve) => {
resolveForDuplicateFileModal = resolve;
duplicatedFile = file;
isDuplicateFileModalOpen = true;
});
})
.then((res) => {
if (!res) return;
// TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
window.alert(`'${file.name}' 파일이 업로드되었어요.`);
})
.catch((e: Error) => {
// TODO: FIXME
console.error(e);
window.alert(`'${file.name}' 파일 업로드에 실패했어요.\n${e.message}`);
});
}
fileInput!.value = "";
};
onMount(async () => {
if (!$hmacSecretStore && !(await requestHmacSecretDownload($masterKeyStore?.get(1)?.key!))) {
throw new Error("Failed to download hmac secrets");
}
});
$effect(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
@@ -58,7 +92,7 @@
<title>파일</title>
</svelte:head>
<input bind:this={fileInput} onchange={uploadFile} type="file" class="hidden" />
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
<div class="flex min-h-full flex-col px-4">
{#if data.id !== "root"}
@@ -67,6 +101,10 @@
{#if $info}
{@const topMargin = data.id === "root" ? "mt-4" : ""}
<div class="mb-4 flex flex-grow flex-col {topMargin}">
<div class="flex gap-x-2">
<UploadStatusCard onclick={() => goto("/file/uploads")} />
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
</div>
{#key $info}
<DirectoryEntries
info={$info}
@@ -99,6 +137,22 @@
}}
/>
<CreateDirectoryModal bind:isOpen={isCreateDirectoryModalOpen} onCreateClick={createDirectory} />
<DuplicateFileModal
bind:isOpen={isDuplicateFileModalOpen}
file={duplicatedFile}
onclose={() => {
resolveForDuplicateFileModal?.(false);
resolveForDuplicateFileModal = undefined;
duplicatedFile = undefined;
isDuplicateFileModalOpen = false;
}}
onDuplicateClick={() => {
resolveForDuplicateFileModal?.(true);
resolveForDuplicateFileModal = undefined;
duplicatedFile = undefined;
isDuplicateFileModalOpen = false;
}}
/>
<DirectoryEntryMenuBottomSheet
bind:isOpen={isDirectoryEntryMenuBottomSheetOpen}

View File

@@ -1,11 +1,22 @@
<script lang="ts">
import { untrack } from "svelte";
import type { Writable } from "svelte/store";
import { getDirectoryInfo, getFileInfo } from "$lib/modules/file";
import { masterKeyStore, type DirectoryInfo, type FileInfo } from "$lib/stores";
import { get, type Writable } from "svelte/store";
import {
getDirectoryInfo,
getFileInfo,
type DirectoryInfo,
type FileInfo,
} from "$lib/modules/filesystem";
import {
fileUploadStatusStore,
isFileUploading,
masterKeyStore,
type FileUploadStatus,
} from "$lib/stores";
import File from "./File.svelte";
import SubDirectory from "./SubDirectory.svelte";
import { SortBy, sortEntries } from "./service";
import UploadingFile from "./UploadingFile.svelte";
import type { SelectedDirectoryEntry } from "../service";
interface Props {
@@ -17,37 +28,95 @@
let { info, onEntryClick, onEntryMenuClick, sortBy = SortBy.NAME_ASC }: Props = $props();
let subDirectoryInfos: Writable<DirectoryInfo | null>[] = $state([]);
let fileInfos: Writable<FileInfo | null>[] = $state([]);
interface DirectoryEntry {
name?: string;
info: Writable<DirectoryInfo | null>;
}
type FileEntry =
| {
type: "file";
name?: string;
info: Writable<FileInfo | null>;
}
| {
type: "uploading-file";
name: string;
info: Writable<FileUploadStatus>;
};
let subDirectories: DirectoryEntry[] = $state([]);
let files: FileEntry[] = $state([]);
$effect(() => {
// TODO: Fix duplicated requests
subDirectoryInfos = info.subDirectoryIds.map((id) =>
getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!),
subDirectories = info.subDirectoryIds.map((id) => {
const info = getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!);
return { name: get(info)?.name, info };
});
files = info.fileIds
.map((id): FileEntry => {
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
return {
type: "file",
name: get(info)?.name,
info,
};
})
.concat(
$fileUploadStatusStore
.filter((statusStore) => {
const { parentId, status } = get(statusStore);
return parentId === info.id && !isFileUploading(status);
})
.map((status) => ({
type: "uploading-file",
name: get(status).name,
info: status,
})),
);
fileInfos = info.fileIds.map((id) => getFileInfo(id, $masterKeyStore?.get(1)?.key!));
const sort = () => {
sortEntries(subDirectoryInfos, sortBy);
sortEntries(fileInfos, sortBy);
sortEntries(subDirectories, sortBy);
sortEntries(files, sortBy);
};
return untrack(() => {
const unsubscribes = subDirectoryInfos
.map((subDirectoryInfo) => subDirectoryInfo.subscribe(sort))
.concat(fileInfos.map((fileInfo) => fileInfo.subscribe(sort)));
sort();
const unsubscribes = subDirectories
.map((subDirectory) =>
subDirectory.info.subscribe((value) => {
if (subDirectory.name === value?.name) return;
subDirectory.name = value?.name;
sort();
}),
)
.concat(
files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
{#if info.subDirectoryIds.length + info.fileIds.length > 0}
<div class="pb-[4.5rem]">
{#each subDirectoryInfos as subDirectory}
<SubDirectory info={subDirectory} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{#if subDirectories.length + files.length > 0}
<div class="space-y-1 pb-[4.5rem]">
{#each subDirectories as { info }}
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{/each}
{#each fileInfos as file}
<File info={file} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{#each files as file}
{#if file.type === "file"}
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else}
<UploadingFile status={file.info} />
{/if}
{/each}
</div>
{:else}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileInfo } from "$lib/stores";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/modules/util";
import type { SelectedDirectoryEntry } from "../service";
import IconDraft from "~icons/material-symbols/draft";
@@ -16,6 +17,8 @@
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info!;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
setTimeout(() => {
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
}, 100);
@@ -25,6 +28,8 @@
e.stopPropagation();
const { id, dataKey, dataKeyVersion, name } = $info!;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
setTimeout(() => {
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
}, 100);
@@ -34,14 +39,19 @@
{#if $info}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div id="button" onclick={openFile} class="h-12 rounded-xl">
<div id="button" onclick={openFile} class="h-14 rounded-xl">
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
<div class="flex-shrink-0 text-lg">
<IconDraft class="text-blue-400" />
</div>
<p title={$info.name} class="flex-grow truncate font-medium">
<div class="flex-grow overflow-hidden">
<p title={$info.name} class="truncate font-medium">
{$info.name}
</p>
<p class="text-xs text-gray-800">
{formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
</p>
</div>
<button
id="open-menu"
onclick={openMenu}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { DirectoryInfo } from "$lib/stores";
import type { DirectoryInfo } from "$lib/modules/filesystem";
import type { SelectedDirectoryEntry } from "../service";
import IconFolder from "~icons/material-symbols/folder";
@@ -18,6 +18,8 @@
const openDirectory = () => {
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
setTimeout(() => {
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
}, 100);
@@ -27,6 +29,8 @@
e.stopPropagation();
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
setTimeout(() => {
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
}, 100);
@@ -36,7 +40,7 @@
{#if $info}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div id="button" onclick={openDirectory} class="h-12 rounded-xl">
<div id="button" onclick={openDirectory} class="h-14 rounded-xl">
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
<div class="flex-shrink-0 text-lg">
<IconFolder />

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import { isFileUploading, type FileUploadStatus } from "$lib/stores";
import IconDraft from "~icons/material-symbols/draft";
interface Props {
status: Writable<FileUploadStatus>;
}
let { status }: Props = $props();
</script>
{#if isFileUploading($status.status)}
<div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg">
<IconDraft class="text-gray-600" />
</div>
<div class="flex flex-grow flex-col overflow-hidden text-gray-800">
<p title={$status.name} class="truncate font-medium">
{$status.name}
</p>
<p class="text-xs">
{#if $status.status === "encryption-pending"}
준비 중
{:else if $status.status === "encrypting"}
암호화하는 중
{:else if $status.status === "upload-pending"}
업로드를 기다리는 중
{:else if $status.status === "uploading"}
전송됨 {Math.floor(($status.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
{/if}
</p>
</div>
</div>
{/if}

View File

@@ -1,22 +1,21 @@
import { get, type Writable } from "svelte/store";
import type { DirectoryInfo, FileInfo } from "$lib/stores";
export enum SortBy {
NAME_ASC,
NAME_DESC,
}
type SortFunc = (a: DirectoryInfo | FileInfo | null, b: DirectoryInfo | FileInfo | null) => number;
type SortFunc = (a?: string, b?: string) => number;
const sortByNameAsc: SortFunc = (a, b) => {
if (a && b) return a.name!.localeCompare(b.name!);
if (a && b) return a.localeCompare(b);
if (a) return -1;
if (b) return 1;
return 0;
};
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
export const sortEntries = <T extends DirectoryInfo | FileInfo>(
entries: Writable<T | null>[],
export const sortEntries = <T extends { name?: string }>(
entries: T[],
sortBy: SortBy = SortBy.NAME_ASC,
) => {
let sortFunc: SortFunc;
@@ -26,5 +25,5 @@ export const sortEntries = <T extends DirectoryInfo | FileInfo>(
sortFunc = sortByNameDesc;
}
entries.sort((a, b) => sortFunc(get(a), get(b)));
entries.sort((a, b) => sortFunc(a.name, b.name));
};

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { fileDownloadStatusStore, isFileDownloading, type FileDownloadStatus } from "$lib/stores";
interface Props {
onclick: () => void;
}
let { onclick }: Props = $props();
let downloadingFiles: Writable<FileDownloadStatus>[] = $state([]);
$effect(() => {
downloadingFiles = $fileDownloadStatusStore.filter((status) =>
isFileDownloading(get(status).status),
);
return untrack(() => {
const unsubscribes = downloadingFiles.map((downloadingFile) =>
downloadingFile.subscribe(({ status }) => {
if (!isFileDownloading(status)) {
downloadingFiles = downloadingFiles.filter((file) => file !== downloadingFile);
}
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
{#if downloadingFiles.length > 0}
<button
onclick={() => setTimeout(onclick, 100)}
class="mb-4 max-w-[50%] flex-1 rounded-xl bg-green-100 p-3 active:bg-green-200"
>
<div class="text-left transition active:scale-95">
<p class="text-xs text-gray-800">진행 중인 다운로드</p>
<p class="font-medium text-green-800">{downloadingFiles.length}</p>
</div>
</button>
{/if}

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { Modal } from "$lib/components";
import { Button } from "$lib/components/buttons";
interface Props {
file: File | undefined;
onclose: () => void;
onDuplicateClick: () => void;
isOpen: boolean;
}
let { file, onclose, onDuplicateClick, isOpen = $bindable() }: Props = $props();
</script>
<Modal bind:isOpen {onclose}>
{#if file}
{@const { name } = file}
{@const nameShort = name.length > 20 ? `${name.slice(0, 20)}...` : name}
<div class="space-y-4">
<div class="space-y-2 break-keep">
<p class="text-xl font-bold">'{nameShort}' 파일이 있어요.</p>
<p>예전에 이미 업로드된 파일이에요. 그래도 업로드할까요?</p>
</div>
<div class="flex gap-2">
<Button color="gray" onclick={onclose}>아니요</Button>
<Button onclick={onDuplicateClick}>업로드할게요</Button>
</div>
</div>
{/if}
</Modal>

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