mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-04 16:16:55 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aef43b8bfa | ||
|
|
b8e1584575 | ||
|
|
c24e84a79c | ||
|
|
3d620d716d | ||
|
|
da47a07da7 | ||
|
|
d0d4afd2c3 | ||
|
|
811713cd03 | ||
|
|
bde090c464 | ||
|
|
620d174e9b | ||
|
|
7aa6ba0eab | ||
|
|
7e711c1b8f | ||
|
|
0ed3d17fef | ||
|
|
40745d5da4 | ||
|
|
cc9d355ac1 | ||
|
|
937c4e2453 | ||
|
|
366f657113 | ||
|
|
9f9c52ff94 | ||
|
|
ed4da7b1df | ||
|
|
f4b9f87087 | ||
|
|
6015a9bca4 | ||
|
|
4bd666a5d5 | ||
|
|
27d2b83464 | ||
|
|
f37df53991 | ||
|
|
ea0f0e4a71 | ||
|
|
9ab107794a | ||
|
|
919a67fedf | ||
|
|
f914026922 | ||
|
|
7f128cccf6 | ||
|
|
8a620fac78 | ||
|
|
b8c7cda4d5 | ||
|
|
6a64bb45f2 | ||
|
|
68a764bf28 | ||
|
|
8bb4d70fa5 | ||
|
|
299787537e | ||
|
|
bd1e9cf54f | ||
|
|
e887fcf137 | ||
|
|
5c7dc58f03 | ||
|
|
59c8523e25 | ||
|
|
805d7df182 | ||
|
|
004e41b0cf | ||
|
|
f8115f4f2e | ||
|
|
aebbc6d0c0 | ||
|
|
85ebb529ba | ||
|
|
be8587694e | ||
|
|
1a86c8d9e0 | ||
|
|
0bdf990dae | ||
|
|
045eb69487 |
@@ -1,10 +1,9 @@
|
|||||||
# Required environment variables
|
# Required environment variables
|
||||||
JWT_SECRET=
|
SESSION_SECRET=
|
||||||
|
|
||||||
# Optional environment variables
|
# Optional environment variables
|
||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
JWT_ACCESS_TOKEN_EXPIRES=
|
SESSION_EXPIRES=
|
||||||
JWT_REFRESH_TOKEN_EXPIRES=
|
|
||||||
USER_CLIENT_CHALLENGE_EXPIRES=
|
USER_CLIENT_CHALLENGE_EXPIRES=
|
||||||
TOKEN_UPGRADE_CHALLENGE_EXPIRES=
|
SESSION_UPGRADE_CHALLENGE_EXPIRES=
|
||||||
LIBRARY_PATH=
|
LIBRARY_PATH=
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Base Image
|
# Base Image
|
||||||
FROM node:18-alpine AS base
|
FROM node:22-alpine AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g pnpm@8
|
RUN npm install -g pnpm@9
|
||||||
COPY pnpm-lock.yaml .
|
COPY pnpm-lock.yaml .
|
||||||
|
|
||||||
# Build Stage
|
# Build Stage
|
||||||
@@ -13,6 +13,8 @@ COPY . .
|
|||||||
RUN pnpm install --offline
|
RUN pnpm install --offline
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
|
RUN sed -i "s/http\.createServer()/http.createServer({ requestTimeout: 0 })/g" ./build/index.js
|
||||||
|
|
||||||
# Deploy Stage
|
# Deploy Stage
|
||||||
FROM base
|
FROM base
|
||||||
RUN pnpm fetch --prod
|
RUN pnpm fetch --prod
|
||||||
|
|||||||
@@ -30,12 +30,11 @@ docker compose up --build -d
|
|||||||
필수 환경 변수가 아닌 경우, 설정해야 하는 특별한 이유가 없다면 기본값을 사용하는 것이 좋아요.
|
필수 환경 변수가 아닌 경우, 설정해야 하는 특별한 이유가 없다면 기본값을 사용하는 것이 좋아요.
|
||||||
|
|
||||||
|이름|필수|기본값|설명|
|
|이름|필수|기본값|설명|
|
||||||
|-:|:-:|:-:|:-|
|
|:-|:-:|:-:|:-|
|
||||||
|`JWT_SECRET`|Y||JWT의 서명을 위해 사용돼요. 안전한 값으로 설정해 주세요.|
|
|`SESSION_SECRET`|Y||Session ID의 서명을 위해 사용돼요. 안전한 값으로 설정해 주세요.|
|
||||||
|`JWT_ACCESS_TOKEN_EXPIRES`||`5m`|Access Token의 유효 시간이에요.|
|
|`SESSION_EXPIRES`||`14d`|Session의 유효 시간이에요. Session은 마지막으로 사용된 후 설정된 유효 시간이 지나면 자동으로 삭제돼요.|
|
||||||
|`JWT_REFRESH_TOKEN_EXPIRES`||`14d`|Refresh Token의 유효 시간이에요.|
|
|
||||||
|`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.|
|
|`USER_CLIENT_CHALLENGE_EXPIRES`||`5m`|암호 키를 서버에 처음 등록할 때 사용되는 챌린지의 유효 시간이에요.|
|
||||||
|`TOKEN_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.|
|
|`SESSION_UPGRADE_CHALLENGE_EXPIRES`||`5m`|암호 키와 함께 로그인할 때 사용되는 챌린지의 유효 시간이에요.|
|
||||||
|`TRUST_PROXY`|||신뢰할 수 있는 리버스 프록시의 수예요. 설정할 경우 1 이상의 정수로 설정해 주세요. 프록시에서 `X-Forwarded-For` HTTP 헤더를 올바르게 설정하도록 구성해 주세요.|
|
|`TRUST_PROXY`|||신뢰할 수 있는 리버스 프록시의 수예요. 설정할 경우 1 이상의 정수로 설정해 주세요. 프록시에서 `X-Forwarded-For` HTTP 헤더를 올바르게 설정하도록 구성해 주세요.|
|
||||||
|`NODE_ENV`||`production`|ArkVault의 사용 용도예요. `production`인 경우, 컨테이너가 실행될 때마다 DB 마이그레이션이 자동으로 실행돼요.|
|
|`NODE_ENV`||`production`|ArkVault의 사용 용도예요. `production`인 경우, 컨테이너가 실행될 때마다 DB 마이그레이션이 자동으로 실행돼요.|
|
||||||
|`PORT`||`80`|ArkVault 서버의 포트예요.|
|
|`PORT`||`80`|ArkVault 서버의 포트예요.|
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# ArkVault
|
# ArkVault
|
||||||
- DATABASE_URL=/app/data/database.sqlite
|
- DATABASE_URL=/app/data/database.sqlite
|
||||||
- JWT_SECRET=${JWT_SECRET:?} # Required
|
- SESSION_SECRET=${SESSION_SECRET:?} # Required
|
||||||
- JWT_ACCESS_TOKEN_EXPIRES
|
- SESSION_EXPIRES
|
||||||
- JWT_REFRESH_TOKEN_EXPIRES
|
|
||||||
- USER_CLIENT_CHALLENGE_EXPIRES
|
- USER_CLIENT_CHALLENGE_EXPIRES
|
||||||
- TOKEN_UPGRADE_CHALLENGE_EXPIRES
|
- SESSION_UPGRADE_CHALLENGE_EXPIRES
|
||||||
- LIBRARY_PATH=/app/data/library
|
- LIBRARY_PATH=/app/data/library
|
||||||
# SvelteKit
|
# SvelteKit
|
||||||
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
|
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
|
||||||
|
|||||||
@@ -17,17 +17,16 @@ CREATE TABLE `user_client_challenge` (
|
|||||||
`id` integer PRIMARY KEY NOT NULL,
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
`user_id` integer NOT NULL,
|
`user_id` integer NOT NULL,
|
||||||
`client_id` integer NOT NULL,
|
`client_id` integer NOT NULL,
|
||||||
`challenge` text NOT NULL,
|
`answer` text NOT NULL,
|
||||||
`allowed_ip` text NOT NULL,
|
`allowed_ip` text NOT NULL,
|
||||||
`expires_at` integer 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 (`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
|
--> statement-breakpoint
|
||||||
CREATE TABLE `directory` (
|
CREATE TABLE `directory` (
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
`created_at` integer NOT NULL,
|
|
||||||
`parent_id` integer,
|
`parent_id` integer,
|
||||||
`user_id` integer NOT NULL,
|
`user_id` integer NOT NULL,
|
||||||
`master_encryption_key_version` 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
|
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
|
--> 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` (
|
CREATE TABLE `file` (
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
`path` text NOT NULL,
|
|
||||||
`parent_id` integer,
|
`parent_id` integer,
|
||||||
`created_at` integer NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
`user_id` integer NOT NULL,
|
||||||
|
`path` text NOT NULL,
|
||||||
`master_encryption_key_version` integer NOT NULL,
|
`master_encryption_key_version` integer NOT NULL,
|
||||||
`encrypted_data_encryption_key` text NOT NULL,
|
`encrypted_data_encryption_key` text NOT NULL,
|
||||||
`data_encryption_key_version` integer NOT NULL,
|
`data_encryption_key_version` integer NOT NULL,
|
||||||
|
`hmac_secret_key_version` integer,
|
||||||
|
`content_hmac` text,
|
||||||
`content_type` text NOT NULL,
|
`content_type` text NOT NULL,
|
||||||
`encrypted_content_iv` text NOT NULL,
|
`encrypted_content_iv` text NOT NULL,
|
||||||
`encrypted_name` 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 (`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`) 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
|
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
|
--> 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` (
|
CREATE TABLE `client_master_encryption_key` (
|
||||||
`user_id` integer NOT NULL,
|
`user_id` integer NOT NULL,
|
||||||
`client_id` integer NOT NULL,
|
`client_id` integer NOT NULL,
|
||||||
@@ -71,49 +113,63 @@ CREATE TABLE `client_master_encryption_key` (
|
|||||||
CREATE TABLE `master_encryption_key` (
|
CREATE TABLE `master_encryption_key` (
|
||||||
`user_id` integer NOT NULL,
|
`user_id` integer NOT NULL,
|
||||||
`version` integer NOT NULL,
|
`version` integer NOT NULL,
|
||||||
`created_by` integer NOT NULL,
|
|
||||||
`created_at` integer NOT NULL,
|
|
||||||
`state` text NOT NULL,
|
`state` text NOT NULL,
|
||||||
`retired_at` integer,
|
`retired_at` integer,
|
||||||
PRIMARY KEY(`user_id`, `version`),
|
PRIMARY KEY(`user_id`, `version`),
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`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 (`created_by`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`user_id` integer NOT NULL,
|
`user_id` integer NOT NULL,
|
||||||
`client_id` integer,
|
`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 (`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
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE `token_upgrade_challenge` (
|
CREATE TABLE `session_upgrade_challenge` (
|
||||||
`id` integer PRIMARY KEY NOT NULL,
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
`refresh_token_id` text NOT NULL,
|
`session_id` text NOT NULL,
|
||||||
`client_id` integer NOT NULL,
|
`client_id` integer NOT NULL,
|
||||||
`challenge` text NOT NULL,
|
`answer` text NOT NULL,
|
||||||
`allowed_ip` text NOT NULL,
|
`allowed_ip` text NOT NULL,
|
||||||
`expires_at` integer NOT NULL,
|
`expires_at` integer NOT NULL,
|
||||||
`is_used` integer DEFAULT false NOT NULL,
|
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
FOREIGN KEY (`refresh_token_id`) REFERENCES `refresh_token`(`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
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE `user` (
|
CREATE TABLE `user` (
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
`email` text NOT NULL,
|
`email` text NOT NULL,
|
||||||
`password` text NOT NULL
|
`password` text NOT NULL,
|
||||||
|
`nickname` text NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE UNIQUE INDEX `client_encryption_public_key_unique` ON `client` (`encryption_public_key`);--> 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_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 `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 `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_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 `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 `hmac_secret_key_encrypted_key_unique` ON `hmac_secret_key` (`encrypted_key`);--> statement-breakpoint
|
||||||
CREATE UNIQUE INDEX `token_upgrade_challenge_challenge_unique` ON `token_upgrade_challenge` (`challenge`);--> 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`);
|
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);
|
||||||
2
drizzle/0001_blushing_alice.sql
Normal file
2
drizzle/0001_blushing_alice.sql
Normal 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;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "929c6bca-d0c0-4899-afc6-a0a498226f28",
|
"id": "928e5669-81cf-486c-9122-8ee64fc9f457",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"client": {
|
"client": {
|
||||||
@@ -147,8 +147,8 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"challenge": {
|
"answer": {
|
||||||
"name": "challenge",
|
"name": "answer",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -167,21 +167,13 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
|
||||||
"is_used": {
|
|
||||||
"name": "is_used",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
"user_client_challenge_challenge_unique": {
|
"user_client_challenge_answer_unique": {
|
||||||
"name": "user_client_challenge_challenge_unique",
|
"name": "user_client_challenge_answer_unique",
|
||||||
"columns": [
|
"columns": [
|
||||||
"challenge"
|
"answer"
|
||||||
],
|
],
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
}
|
}
|
||||||
@@ -212,6 +204,21 @@
|
|||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "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": {},
|
"compositePrimaryKeys": {},
|
||||||
@@ -227,13 +234,6 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": true
|
"autoincrement": true
|
||||||
},
|
},
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"parent_id": {
|
"parent_id": {
|
||||||
"name": "parent_id",
|
"name": "parent_id",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -332,6 +332,64 @@
|
|||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {},
|
||||||
"uniqueConstraints": {}
|
"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": {
|
"file": {
|
||||||
"name": "file",
|
"name": "file",
|
||||||
"columns": {
|
"columns": {
|
||||||
@@ -342,13 +400,6 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": true
|
"autoincrement": true
|
||||||
},
|
},
|
||||||
"path": {
|
|
||||||
"name": "path",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"parent_id": {
|
"parent_id": {
|
||||||
"name": "parent_id",
|
"name": "parent_id",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -356,16 +407,16 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"created_at": {
|
"user_id": {
|
||||||
"name": "created_at",
|
"name": "user_id",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"user_id": {
|
"path": {
|
||||||
"name": "user_id",
|
"name": "path",
|
||||||
"type": "integer",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -391,6 +442,20 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"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": {
|
"content_type": {
|
||||||
"name": "content_type",
|
"name": "content_type",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -470,6 +535,261 @@
|
|||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "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": {},
|
"compositePrimaryKeys": {},
|
||||||
@@ -587,20 +907,6 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"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": {
|
"state": {
|
||||||
"name": "state",
|
"name": "state",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -630,19 +936,6 @@
|
|||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "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": {
|
"compositePrimaryKeys": {
|
||||||
@@ -656,8 +949,101 @@
|
|||||||
},
|
},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
},
|
},
|
||||||
"refresh_token": {
|
"master_encryption_key_log": {
|
||||||
"name": "refresh_token",
|
"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": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -680,17 +1066,38 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"expires_at": {
|
"created_at": {
|
||||||
"name": "expires_at",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"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": {
|
"indexes": {
|
||||||
"refresh_token_user_id_client_id_unique": {
|
"session_user_id_client_id_unique": {
|
||||||
"name": "refresh_token_user_id_client_id_unique",
|
"name": "session_user_id_client_id_unique",
|
||||||
"columns": [
|
"columns": [
|
||||||
"user_id",
|
"user_id",
|
||||||
"client_id"
|
"client_id"
|
||||||
@@ -699,9 +1106,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"refresh_token_user_id_user_id_fk": {
|
"session_user_id_user_id_fk": {
|
||||||
"name": "refresh_token_user_id_user_id_fk",
|
"name": "session_user_id_user_id_fk",
|
||||||
"tableFrom": "refresh_token",
|
"tableFrom": "session",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"user_id"
|
"user_id"
|
||||||
@@ -712,9 +1119,9 @@
|
|||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"refresh_token_client_id_client_id_fk": {
|
"session_client_id_client_id_fk": {
|
||||||
"name": "refresh_token_client_id_client_id_fk",
|
"name": "session_client_id_client_id_fk",
|
||||||
"tableFrom": "refresh_token",
|
"tableFrom": "session",
|
||||||
"tableTo": "client",
|
"tableTo": "client",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"client_id"
|
"client_id"
|
||||||
@@ -729,8 +1136,8 @@
|
|||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
},
|
},
|
||||||
"token_upgrade_challenge": {
|
"session_upgrade_challenge": {
|
||||||
"name": "token_upgrade_challenge",
|
"name": "session_upgrade_challenge",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -739,8 +1146,8 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"refresh_token_id": {
|
"session_id": {
|
||||||
"name": "refresh_token_id",
|
"name": "session_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -753,8 +1160,8 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"challenge": {
|
"answer": {
|
||||||
"name": "challenge",
|
"name": "answer",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -773,32 +1180,31 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
|
||||||
"is_used": {
|
|
||||||
"name": "is_used",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
"token_upgrade_challenge_challenge_unique": {
|
"session_upgrade_challenge_session_id_unique": {
|
||||||
"name": "token_upgrade_challenge_challenge_unique",
|
"name": "session_upgrade_challenge_session_id_unique",
|
||||||
"columns": [
|
"columns": [
|
||||||
"challenge"
|
"session_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"session_upgrade_challenge_answer_unique": {
|
||||||
|
"name": "session_upgrade_challenge_answer_unique",
|
||||||
|
"columns": [
|
||||||
|
"answer"
|
||||||
],
|
],
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"token_upgrade_challenge_refresh_token_id_refresh_token_id_fk": {
|
"session_upgrade_challenge_session_id_session_id_fk": {
|
||||||
"name": "token_upgrade_challenge_refresh_token_id_refresh_token_id_fk",
|
"name": "session_upgrade_challenge_session_id_session_id_fk",
|
||||||
"tableFrom": "token_upgrade_challenge",
|
"tableFrom": "session_upgrade_challenge",
|
||||||
"tableTo": "refresh_token",
|
"tableTo": "session",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"refresh_token_id"
|
"session_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
@@ -806,9 +1212,9 @@
|
|||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"token_upgrade_challenge_client_id_client_id_fk": {
|
"session_upgrade_challenge_client_id_client_id_fk": {
|
||||||
"name": "token_upgrade_challenge_client_id_client_id_fk",
|
"name": "session_upgrade_challenge_client_id_client_id_fk",
|
||||||
"tableFrom": "token_upgrade_challenge",
|
"tableFrom": "session_upgrade_challenge",
|
||||||
"tableTo": "client",
|
"tableTo": "client",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"client_id"
|
"client_id"
|
||||||
@@ -846,6 +1252,13 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"nickname": {
|
||||||
|
"name": "nickname",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
|||||||
1301
drizzle/meta/0001_snapshot.json
Normal file
1301
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,15 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1736170919561,
|
"when": 1736704436996,
|
||||||
"tag": "0000_handy_captain_marvel",
|
"tag": "0000_unknown_stark_industries",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1736720831242,
|
||||||
|
"tag": "0001_blushing_alice",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
50
package.json
50
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "arkvault",
|
"name": "arkvault",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -17,46 +17,52 @@
|
|||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.2.3",
|
"@eslint/compat": "^1.2.4",
|
||||||
"@iconify-json/material-symbols": "^1.2.12",
|
"@iconify-json/material-symbols": "^1.2.12",
|
||||||
"@sveltejs/adapter-node": "^5.2.9",
|
"@sveltejs/adapter-node": "^5.2.11",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.15.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
|
||||||
"@types/ms": "^0.7.34",
|
"@types/ms": "^0.7.34",
|
||||||
"@types/node-schedule": "^2.1.7",
|
"@types/node-schedule": "^2.1.7",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"axios": "^1.7.9",
|
||||||
"dexie": "^4.0.10",
|
"dexie": "^4.0.10",
|
||||||
"drizzle-kit": "^0.22.0",
|
"drizzle-kit": "^0.22.8",
|
||||||
"eslint": "^9.7.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-prettier": "^9.1.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",
|
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||||
|
"exifreader": "^4.26.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"globals": "^15.0.0",
|
"globals": "^15.14.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"mime": "^4.0.6",
|
"mime": "^4.0.6",
|
||||||
"prettier": "^3.3.2",
|
"p-limit": "^6.2.0",
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
"prettier-plugin-svelte": "^3.3.2",
|
||||||
"svelte": "^5.0.0",
|
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte": "^5.17.1",
|
||||||
"tailwindcss": "^3.4.9",
|
"svelte-check": "^4.1.3",
|
||||||
"typescript": "^5.0.0",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript-eslint": "^8.0.0",
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.19.1",
|
||||||
"unplugin-icons": "^0.22.0",
|
"unplugin-icons": "^0.22.0",
|
||||||
"vite": "^5.4.11"
|
"vite": "^5.4.11"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/busboy": "^3.1.1",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
"better-sqlite3": "^11.1.2",
|
"better-sqlite3": "^11.7.2",
|
||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"node-schedule": "^2.1.1",
|
"node-schedule": "^2.1.1",
|
||||||
"uuid": "^11.0.3",
|
"uuid": "^11.0.4",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.0.0",
|
||||||
|
"pnpm": "^9.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4534
pnpm-lock.yaml
generated
4534
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
14
src/app.d.ts
vendored
14
src/app.d.ts
vendored
@@ -5,11 +5,15 @@ import "unplugin-icons/types/svelte";
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
interface Locals {
|
||||||
// interface Locals {}
|
ip: string;
|
||||||
// interface PageData {}
|
userAgent: string;
|
||||||
// interface PageState {}
|
session?: {
|
||||||
// interface Platform {}
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
clientId?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { ClientInit } from "@sveltejs/kit";
|
import type { ClientInit } from "@sveltejs/kit";
|
||||||
import { getClientKey, getMasterKeys } from "$lib/indexedDB";
|
import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
|
||||||
import { clientKeyStore, masterKeyStore } from "$lib/stores";
|
import { prepareFileCache } from "$lib/modules/file";
|
||||||
|
import { prepareOpfs } from "$lib/modules/opfs";
|
||||||
|
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
|
||||||
|
|
||||||
const prepareClientKeyStore = async () => {
|
const prepareClientKeyStore = async () => {
|
||||||
const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([
|
const [encryptKey, decryptKey, signKey, verifyKey] = await Promise.all([
|
||||||
@@ -21,6 +23,21 @@ const prepareMasterKeyStore = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const init: ClientInit = async () => {
|
const prepareHmacSecretStore = async () => {
|
||||||
await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore()]);
|
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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 schedule from "node-schedule";
|
||||||
import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
|
import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
|
||||||
import { migrateDB } from "$lib/server/db/drizzle";
|
import { migrateDB } from "$lib/server/db/drizzle";
|
||||||
import {
|
import {
|
||||||
cleanupExpiredRefreshTokens,
|
cleanupExpiredSessions,
|
||||||
cleanupExpiredTokenUpgradeChallenges,
|
cleanupExpiredSessionUpgradeChallenges,
|
||||||
} from "$lib/server/db/token";
|
} from "$lib/server/db/session";
|
||||||
|
import { authenticate, setAgentInfo } from "$lib/server/middlewares";
|
||||||
|
|
||||||
export const init: ServerInit = () => {
|
export const init: ServerInit = () => {
|
||||||
migrateDB();
|
migrateDB();
|
||||||
|
|
||||||
schedule.scheduleJob("0 * * * *", () => {
|
schedule.scheduleJob("0 * * * *", () => {
|
||||||
cleanupExpiredUserClientChallenges();
|
cleanupExpiredUserClientChallenges();
|
||||||
cleanupExpiredRefreshTokens();
|
cleanupExpiredSessions();
|
||||||
cleanupExpiredTokenUpgradeChallenges();
|
cleanupExpiredSessionUpgradeChallenges();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle = sequence(setAgentInfo, authenticate);
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
onclick?.();
|
onclick?.();
|
||||||
}, 100);
|
}, 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">
|
<div class="h-full w-full p-3 transition active:scale-95">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -3,15 +3,16 @@
|
|||||||
import type { SvelteHTMLElements } from "svelte/elements";
|
import type { SvelteHTMLElements } from "svelte/elements";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
icon?: Component<SvelteHTMLElements["svg"]>;
|
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
|
icon?: Component<SvelteHTMLElements["svg"]>;
|
||||||
|
topPadding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { icon: Icon, children }: Props = $props();
|
let { topPadding = true, children, icon: Icon }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<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}
|
{#if Icon}
|
||||||
<Icon class="text-5xl text-gray-600" />
|
<Icon class="text-5xl text-gray-600" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,35 +1,11 @@
|
|||||||
export const refreshToken = async (fetchInternal = fetch) => {
|
export const callGetApi = async (input: RequestInfo, fetchInternal = fetch) => {
|
||||||
return await fetchInternal("/api/auth/refreshToken", { method: "POST" });
|
return await fetchInternal(input);
|
||||||
};
|
};
|
||||||
|
|
||||||
const callApi = async (input: RequestInfo, init?: RequestInit, fetchInternal = fetch) => {
|
export const callPostApi = async <T>(input: RequestInfo, payload?: T, fetchInternal = fetch) => {
|
||||||
let res = await fetchInternal(input, init);
|
return await fetchInternal(input, {
|
||||||
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,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: payload ? JSON.stringify(payload) : undefined,
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
},
|
});
|
||||||
fetchInternal,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface KeyExportState {
|
|||||||
verifyKeyBase64: string;
|
verifyKeyBase64: string;
|
||||||
|
|
||||||
masterKeyWrapped: string;
|
masterKeyWrapped: string;
|
||||||
|
hmacSecretWrapped: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useAutoNull = <T>(value: T | null) => {
|
const useAutoNull = <T>(value: T | null) => {
|
||||||
|
|||||||
28
src/lib/indexedDB/cacheIndex.ts
Normal file
28
src/lib/indexedDB/cacheIndex.ts
Normal 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);
|
||||||
|
};
|
||||||
86
src/lib/indexedDB/filesystem.ts
Normal file
86
src/lib/indexedDB/filesystem.ts
Normal 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(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
3
src/lib/indexedDB/index.ts
Normal file
3
src/lib/indexedDB/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./cacheIndex";
|
||||||
|
export * from "./filesystem";
|
||||||
|
export * from "./keyStore";
|
||||||
@@ -7,22 +7,28 @@ interface ClientKey {
|
|||||||
key: CryptoKey;
|
key: CryptoKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MasterKeyState = "active" | "retired";
|
|
||||||
|
|
||||||
interface MasterKey {
|
interface MasterKey {
|
||||||
version: number;
|
version: number;
|
||||||
state: MasterKeyState;
|
state: "active" | "retired";
|
||||||
key: CryptoKey;
|
key: CryptoKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HmacSecret {
|
||||||
|
version: number;
|
||||||
|
state: "active";
|
||||||
|
secret: CryptoKey;
|
||||||
|
}
|
||||||
|
|
||||||
const keyStore = new Dexie("keyStore") as Dexie & {
|
const keyStore = new Dexie("keyStore") as Dexie & {
|
||||||
clientKey: EntityTable<ClientKey, "usage">;
|
clientKey: EntityTable<ClientKey, "usage">;
|
||||||
masterKey: EntityTable<MasterKey, "version">;
|
masterKey: EntityTable<MasterKey, "version">;
|
||||||
|
hmacSecret: EntityTable<HmacSecret, "version">;
|
||||||
};
|
};
|
||||||
|
|
||||||
keyStore.version(1).stores({
|
keyStore.version(1).stores({
|
||||||
clientKey: "usage",
|
clientKey: "usage",
|
||||||
masterKey: "version",
|
masterKey: "version",
|
||||||
|
hmacSecret: "version",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getClientKey = async (usage: ClientKeyUsage) => {
|
export const getClientKey = async (usage: ClientKeyUsage) => {
|
||||||
@@ -62,3 +68,14 @@ export const storeMasterKeys = async (keys: MasterKey[]) => {
|
|||||||
}
|
}
|
||||||
await keyStore.masterKey.bulkPut(keys);
|
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);
|
||||||
|
};
|
||||||
@@ -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) => {
|
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
|
||||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||||
const ciphertext = await window.crypto.subtle.encrypt(
|
const ciphertext = await window.crypto.subtle.encrypt(
|
||||||
|
|||||||
@@ -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(
|
return await window.crypto.subtle.sign(
|
||||||
{
|
{
|
||||||
name: "RSA-PSS",
|
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,
|
message: BufferSource,
|
||||||
signature: BufferSource,
|
signature: BufferSource,
|
||||||
verifyKey: CryptoKey,
|
verifyKey: CryptoKey,
|
||||||
@@ -131,7 +131,7 @@ export const signMasterKeyWrapped = async (
|
|||||||
version: masterKeyVersion,
|
version: masterKeyVersion,
|
||||||
key: masterKeyWrapped,
|
key: masterKeyWrapped,
|
||||||
});
|
});
|
||||||
return encodeToBase64(await signMessage(encodeString(serialized), signKey));
|
return encodeToBase64(await signMessageRSA(encodeString(serialized), signKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const verifyMasterKeyWrapped = async (
|
export const verifyMasterKeyWrapped = async (
|
||||||
@@ -144,7 +144,7 @@ export const verifyMasterKeyWrapped = async (
|
|||||||
version: masterKeyVersion,
|
version: masterKeyVersion,
|
||||||
key: masterKeyWrapped,
|
key: masterKeyWrapped,
|
||||||
});
|
});
|
||||||
return await verifySignature(
|
return await verifySignatureRSA(
|
||||||
encodeString(serialized),
|
encodeString(serialized),
|
||||||
decodeFromBase64(masterKeyWrappedSig),
|
decodeFromBase64(masterKeyWrappedSig),
|
||||||
verifyKey,
|
verifyKey,
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
export const digestMessage = async (message: BufferSource) => {
|
export const digestMessage = async (message: BufferSource) => {
|
||||||
return await window.crypto.subtle.digest("SHA-256", message);
|
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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
50
src/lib/modules/file/cache.ts
Normal file
50
src/lib/modules/file/cache.ts
Normal 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);
|
||||||
|
};
|
||||||
84
src/lib/modules/file/download.ts
Normal file
84
src/lib/modules/file/download.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
3
src/lib/modules/file/index.ts
Normal file
3
src/lib/modules/file/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./cache";
|
||||||
|
export * from "./download";
|
||||||
|
export * from "./upload";
|
||||||
225
src/lib/modules/file/upload.ts
Normal file
225
src/lib/modules/file/upload.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
206
src/lib/modules/filesystem.ts
Normal file
206
src/lib/modules/filesystem.ts
Normal 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
61
src/lib/modules/opfs.ts
Normal 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
29
src/lib/modules/util.ts
Normal 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`;
|
||||||
|
};
|
||||||
@@ -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 db from "./drizzle";
|
||||||
|
import { IntegrityError } from "./error";
|
||||||
import { client, userClient, userClientChallenge } from "./schema";
|
import { client, userClient, userClientChallenge } from "./schema";
|
||||||
|
|
||||||
export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => {
|
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
|
const clients = await tx
|
||||||
.select()
|
.select({ id: client.id })
|
||||||
.from(client)
|
.from(client)
|
||||||
.where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey)));
|
.where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey)))
|
||||||
if (clients.length > 0) {
|
.limit(1);
|
||||||
throw new Error("Already used public key(s)");
|
if (clients.length !== 0) {
|
||||||
|
throw new IntegrityError("Public key(s) already registered");
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertRes = await tx
|
const newClients = await tx
|
||||||
.insert(client)
|
.insert(client)
|
||||||
.values({ encPubKey, sigPubKey })
|
.values({ encPubKey, sigPubKey })
|
||||||
.returning({ id: client.id });
|
.returning({ id: client.id });
|
||||||
const { id: clientId } = insertRes[0]!;
|
const { id: clientId } = newClients[0]!;
|
||||||
await tx.insert(userClient).values({ userId, clientId });
|
await tx.insert(userClient).values({ userId, clientId });
|
||||||
|
|
||||||
return clientId;
|
return clientId;
|
||||||
});
|
},
|
||||||
|
{ behavior: "exclusive" },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getClient = async (clientId: number) => {
|
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;
|
return clients[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,24 +39,23 @@ export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) =
|
|||||||
.select()
|
.select()
|
||||||
.from(client)
|
.from(client)
|
||||||
.where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey)))
|
.where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey)))
|
||||||
.execute();
|
.limit(1);
|
||||||
return clients[0] ?? null;
|
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) => {
|
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) => {
|
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) => {
|
export const getUserClient = async (userId: number, clientId: number) => {
|
||||||
@@ -58,7 +63,7 @@ export const getUserClient = async (userId: number, clientId: number) => {
|
|||||||
.select()
|
.select()
|
||||||
.from(userClient)
|
.from(userClient)
|
||||||
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
|
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
|
||||||
.execute();
|
.limit(1);
|
||||||
return userClients[0] ?? null;
|
return userClients[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,7 +73,7 @@ export const getUserClientWithDetails = async (userId: number, clientId: number)
|
|||||||
.from(userClient)
|
.from(userClient)
|
||||||
.innerJoin(client, eq(userClient.clientId, client.id))
|
.innerJoin(client, eq(userClient.clientId, client.id))
|
||||||
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
|
.where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId)))
|
||||||
.execute();
|
.limit(1);
|
||||||
return userClients[0] ?? null;
|
return userClients[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,8 +87,7 @@ export const setUserClientStateToPending = async (userId: number, clientId: numb
|
|||||||
eq(userClient.clientId, clientId),
|
eq(userClient.clientId, clientId),
|
||||||
eq(userClient.state, "challenging"),
|
eq(userClient.state, "challenging"),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.execute();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setUserClientStateToActive = async (userId: number, clientId: number) => {
|
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.clientId, clientId),
|
||||||
eq(userClient.state, "pending"),
|
eq(userClient.state, "pending"),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.execute();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const registerUserClientChallenge = async (
|
export const registerUserClientChallenge = async (
|
||||||
@@ -107,45 +110,30 @@ export const registerUserClientChallenge = async (
|
|||||||
allowedIp: string,
|
allowedIp: string,
|
||||||
expiresAt: Date,
|
expiresAt: Date,
|
||||||
) => {
|
) => {
|
||||||
await db
|
await db.insert(userClientChallenge).values({
|
||||||
.insert(userClientChallenge)
|
|
||||||
.values({
|
|
||||||
userId,
|
userId,
|
||||||
clientId,
|
clientId,
|
||||||
answer,
|
answer,
|
||||||
allowedIp,
|
allowedIp,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
})
|
});
|
||||||
.execute();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUserClientChallenge = async (answer: string, ip: string) => {
|
export const consumeUserClientChallenge = async (userId: number, answer: string, ip: string) => {
|
||||||
const challenges = await db
|
const challenges = await db
|
||||||
.select()
|
.delete(userClientChallenge)
|
||||||
.from(userClientChallenge)
|
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
eq(userClientChallenge.userId, userId),
|
||||||
eq(userClientChallenge.answer, answer),
|
eq(userClientChallenge.answer, answer),
|
||||||
eq(userClientChallenge.allowedIp, ip),
|
eq(userClientChallenge.allowedIp, ip),
|
||||||
gt(userClientChallenge.expiresAt, new Date()),
|
gt(userClientChallenge.expiresAt, new Date()),
|
||||||
eq(userClientChallenge.isUsed, false),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.execute();
|
.returning({ clientId: userClientChallenge.clientId });
|
||||||
return challenges[0] ?? null;
|
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 () => {
|
export const cleanupExpiredUserClientChallenges = async () => {
|
||||||
await db
|
await db.delete(userClientChallenge).where(lte(userClientChallenge.expiresAt, new Date()));
|
||||||
.delete(userClientChallenge)
|
|
||||||
.where(lte(userClientChallenge.expiresAt, new Date()))
|
|
||||||
.execute();
|
|
||||||
};
|
};
|
||||||
|
|||||||
26
src/lib/server/db/error.ts
Normal file
26
src/lib/server/db/error.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import db from "./drizzle";
|
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;
|
type DirectoryId = "root" | number;
|
||||||
|
|
||||||
export interface NewDirectoryParams {
|
export interface NewDirectoryParams {
|
||||||
userId: number;
|
|
||||||
parentId: DirectoryId;
|
parentId: DirectoryId;
|
||||||
|
userId: number;
|
||||||
mekVersion: number;
|
mekVersion: number;
|
||||||
encDek: string;
|
encDek: string;
|
||||||
dekVersion: Date;
|
dekVersion: Date;
|
||||||
@@ -15,52 +16,69 @@ export interface NewDirectoryParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NewFileParams {
|
export interface NewFileParams {
|
||||||
path: string;
|
|
||||||
parentId: DirectoryId;
|
parentId: DirectoryId;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
path: string;
|
||||||
mekVersion: number;
|
mekVersion: number;
|
||||||
encDek: string;
|
encDek: string;
|
||||||
dekVersion: Date;
|
dekVersion: Date;
|
||||||
|
hskVersion: number | null;
|
||||||
|
contentHmac: string | null;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
encContentIv: string;
|
encContentIv: string;
|
||||||
encName: string;
|
encName: string;
|
||||||
encNameIv: string;
|
encNameIv: string;
|
||||||
|
encCreatedAt: string | null;
|
||||||
|
encCreatedAtIv: string | null;
|
||||||
|
encLastModifiedAt: string;
|
||||||
|
encLastModifiedAtIv: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const registerNewDirectory = async (params: NewDirectoryParams) => {
|
export const registerDirectory = async (params: NewDirectoryParams) => {
|
||||||
return await db.transaction(async (tx) => {
|
await db.transaction(
|
||||||
|
async (tx) => {
|
||||||
const meks = await tx
|
const meks = await tx
|
||||||
.select()
|
.select({ version: mek.version })
|
||||||
.from(mek)
|
.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) {
|
if (meks[0]?.version !== params.mekVersion) {
|
||||||
throw new Error("Invalid MEK version");
|
throw new IntegrityError("Inactive MEK version");
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const newDirectories = await tx
|
||||||
await tx.insert(directory).values({
|
.insert(directory)
|
||||||
createdAt: now,
|
.values({
|
||||||
parentId: params.parentId === "root" ? null : params.parentId,
|
parentId: params.parentId === "root" ? null : params.parentId,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
mekVersion: params.mekVersion,
|
mekVersion: params.mekVersion,
|
||||||
encDek: params.encDek,
|
encDek: params.encDek,
|
||||||
dekVersion: params.dekVersion,
|
dekVersion: params.dekVersion,
|
||||||
encName: { ciphertext: params.encName, iv: params.encNameIv },
|
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
|
return await db
|
||||||
.select()
|
.select()
|
||||||
.from(directory)
|
.from(directory)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(directory.userId, userId),
|
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) => {
|
export const getDirectory = async (userId: number, directoryId: number) => {
|
||||||
@@ -68,7 +86,7 @@ export const getDirectory = async (userId: number, directoryId: number) => {
|
|||||||
.select()
|
.select()
|
||||||
.from(directory)
|
.from(directory)
|
||||||
.where(and(eq(directory.userId, userId), eq(directory.id, directoryId)))
|
.where(and(eq(directory.userId, userId), eq(directory.id, directoryId)))
|
||||||
.execute();
|
.limit(1);
|
||||||
return res[0] ?? null;
|
return res[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,72 +97,133 @@ export const setDirectoryEncName = async (
|
|||||||
encName: string,
|
encName: string,
|
||||||
encNameIv: 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)
|
.update(directory)
|
||||||
.set({ encName: { ciphertext: encName, iv: encNameIv } })
|
.set({ encName: { ciphertext: encName, iv: encNameIv } })
|
||||||
.where(
|
.where(and(eq(directory.userId, userId), eq(directory.id, directoryId)));
|
||||||
and(
|
await tx.insert(directoryLog).values({
|
||||||
eq(directory.userId, userId),
|
directoryId,
|
||||||
eq(directory.id, directoryId),
|
timestamp: new Date(),
|
||||||
eq(directory.dekVersion, dekVersion),
|
action: "rename",
|
||||||
),
|
newName: { ciphertext: encName, iv: encNameIv },
|
||||||
)
|
});
|
||||||
.execute();
|
},
|
||||||
return res.changes > 0;
|
{ behavior: "exclusive" },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unregisterDirectory = async (userId: number, directoryId: number) => {
|
export const unregisterDirectory = async (userId: number, directoryId: number) => {
|
||||||
return await db.transaction(async (tx) => {
|
return await db.transaction(
|
||||||
const getFilePaths = async (parentId: number) => {
|
async (tx) => {
|
||||||
const files = await tx
|
const unregisterFiles = async (parentId: number) => {
|
||||||
.select({ path: file.path })
|
return await tx
|
||||||
.from(file)
|
.delete(file)
|
||||||
.where(and(eq(file.userId, userId), eq(file.parentId, parentId)));
|
.where(and(eq(file.userId, userId), eq(file.parentId, parentId)))
|
||||||
return files.map(({ path }) => path);
|
.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
|
const subDirectories = await tx
|
||||||
.select({ id: directory.id })
|
.select({ id: directory.id })
|
||||||
.from(directory)
|
.from(directory)
|
||||||
.where(and(eq(directory.userId, userId), eq(directory.parentId, directoryId)));
|
.where(and(eq(directory.userId, userId), eq(directory.parentId, directoryId)));
|
||||||
const subDirectoryFilePaths = await Promise.all(
|
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));
|
const deleteRes = await tx.delete(directory).where(eq(directory.id, directoryId));
|
||||||
await tx.delete(directory).where(eq(directory.id, directoryId));
|
if (deleteRes.changes === 0) {
|
||||||
|
throw new IntegrityError("Directory not found");
|
||||||
return filePaths.concat(...subDirectoryFilePaths);
|
}
|
||||||
|
return files.concat(...subDirectoryFilePaths);
|
||||||
};
|
};
|
||||||
return await unregisterSubDirectoriesRecursively(directoryId);
|
return await unregisterDirectoryRecursively(directoryId);
|
||||||
});
|
},
|
||||||
|
{ behavior: "exclusive" },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const registerNewFile = async (params: NewFileParams) => {
|
export const registerFile = async (params: NewFileParams) => {
|
||||||
await db.transaction(async (tx) => {
|
if (
|
||||||
const meks = await tx
|
(params.hskVersion && !params.contentHmac) ||
|
||||||
.select()
|
(!params.hskVersion && params.contentHmac) ||
|
||||||
.from(mek)
|
(params.encCreatedAt && !params.encCreatedAtIv) ||
|
||||||
.where(and(eq(mek.userId, params.userId), eq(mek.state, "active")));
|
(!params.encCreatedAt && params.encCreatedAtIv)
|
||||||
if (meks[0]?.version !== params.mekVersion) {
|
) {
|
||||||
throw new Error("Invalid MEK version");
|
throw new Error("Invalid arguments");
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
await db.transaction(
|
||||||
await tx.insert(file).values({
|
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,
|
path: params.path,
|
||||||
parentId: params.parentId === "root" ? null : params.parentId,
|
parentId: params.parentId === "root" ? null : params.parentId,
|
||||||
createdAt: now,
|
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
mekVersion: params.mekVersion,
|
mekVersion: params.mekVersion,
|
||||||
|
hskVersion: params.hskVersion,
|
||||||
|
contentHmac: params.contentHmac,
|
||||||
contentType: params.contentType,
|
contentType: params.contentType,
|
||||||
encDek: params.encDek,
|
encDek: params.encDek,
|
||||||
dekVersion: params.dekVersion,
|
dekVersion: params.dekVersion,
|
||||||
encContentIv: params.encContentIv,
|
encContentIv: params.encContentIv,
|
||||||
encName: { ciphertext: params.encName, iv: params.encNameIv },
|
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) => {
|
export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => {
|
||||||
@@ -156,8 +235,24 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectoryId)
|
|||||||
eq(file.userId, userId),
|
eq(file.userId, userId),
|
||||||
parentId === "root" ? isNull(file.parentId) : eq(file.parentId, parentId),
|
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) => {
|
export const getFile = async (userId: number, fileId: number) => {
|
||||||
@@ -165,7 +260,7 @@ export const getFile = async (userId: number, fileId: number) => {
|
|||||||
.select()
|
.select()
|
||||||
.from(file)
|
.from(file)
|
||||||
.where(and(eq(file.userId, userId), eq(file.id, fileId)))
|
.where(and(eq(file.userId, userId), eq(file.id, fileId)))
|
||||||
.execute();
|
.limit(1);
|
||||||
return res[0] ?? null;
|
return res[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,19 +271,41 @@ export const setFileEncName = async (
|
|||||||
encName: string,
|
encName: string,
|
||||||
encNameIv: 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)
|
.update(file)
|
||||||
.set({ encName: { ciphertext: encName, iv: encNameIv } })
|
.set({ encName: { ciphertext: encName, iv: encNameIv } })
|
||||||
.where(and(eq(file.userId, userId), eq(file.id, fileId), eq(file.dekVersion, dekVersion)))
|
.where(and(eq(file.userId, userId), eq(file.id, fileId)));
|
||||||
.execute();
|
await tx.insert(fileLog).values({
|
||||||
return res.changes > 0;
|
fileId,
|
||||||
|
timestamp: new Date(),
|
||||||
|
action: "rename",
|
||||||
|
newName: { ciphertext: encName, iv: encNameIv },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ behavior: "exclusive" },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unregisterFile = async (userId: number, fileId: number) => {
|
export const unregisterFile = async (userId: number, fileId: number) => {
|
||||||
const res = await db
|
const files = await db
|
||||||
.delete(file)
|
.delete(file)
|
||||||
.where(and(eq(file.userId, userId), eq(file.id, fileId)))
|
.where(and(eq(file.userId, userId), eq(file.id, fileId)))
|
||||||
.returning({ path: file.path })
|
.returning({ path: file.path });
|
||||||
.execute();
|
if (!files[0]) {
|
||||||
return res[0]?.path ?? null;
|
throw new IntegrityError("File not found");
|
||||||
|
}
|
||||||
|
return files[0].path;
|
||||||
};
|
};
|
||||||
|
|||||||
46
src/lib/server/db/hsk.ts
Normal file
46
src/lib/server/db/hsk.ts
Normal 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")));
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { SqliteError } from "better-sqlite3";
|
||||||
import { and, or, eq } from "drizzle-orm";
|
import { and, or, eq } from "drizzle-orm";
|
||||||
import db from "./drizzle";
|
import db from "./drizzle";
|
||||||
import { mek, clientMek } from "./schema";
|
import { IntegrityError } from "./error";
|
||||||
|
import { mek, mekLog, clientMek } from "./schema";
|
||||||
|
|
||||||
export const registerInitialMek = async (
|
export const registerInitialMek = async (
|
||||||
userId: number,
|
userId: number,
|
||||||
@@ -8,12 +10,12 @@ export const registerInitialMek = async (
|
|||||||
encMek: string,
|
encMek: string,
|
||||||
encMekSig: string,
|
encMekSig: string,
|
||||||
) => {
|
) => {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(
|
||||||
|
async (tx) => {
|
||||||
|
try {
|
||||||
await tx.insert(mek).values({
|
await tx.insert(mek).values({
|
||||||
userId,
|
userId,
|
||||||
version: 1,
|
version: 1,
|
||||||
createdBy,
|
|
||||||
createdAt: new Date(),
|
|
||||||
state: "active",
|
state: "active",
|
||||||
});
|
});
|
||||||
await tx.insert(clientMek).values({
|
await tx.insert(clientMek).values({
|
||||||
@@ -23,7 +25,22 @@ export const registerInitialMek = async (
|
|||||||
encMek,
|
encMek,
|
||||||
encMekSig,
|
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) => {
|
export const getInitialMek = async (userId: number) => {
|
||||||
@@ -31,19 +48,10 @@ export const getInitialMek = async (userId: number) => {
|
|||||||
.select()
|
.select()
|
||||||
.from(mek)
|
.from(mek)
|
||||||
.where(and(eq(mek.userId, userId), eq(mek.version, 1)))
|
.where(and(eq(mek.userId, userId), eq(mek.version, 1)))
|
||||||
.execute();
|
.limit(1);
|
||||||
return meks[0] ?? null;
|
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) => {
|
export const getAllValidClientMeks = async (userId: number, clientId: number) => {
|
||||||
return await db
|
return await db
|
||||||
.select()
|
.select()
|
||||||
@@ -55,6 +63,5 @@ export const getAllValidClientMeks = async (userId: number, clientId: number) =>
|
|||||||
eq(clientMek.clientId, clientId),
|
eq(clientMek.clientId, clientId),
|
||||||
or(eq(mek.state, "active"), eq(mek.state, "retired")),
|
or(eq(mek.state, "active"), eq(mek.state, "retired")),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.execute();
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
import { user } from "./user";
|
||||||
|
|
||||||
export const client = sqliteTable(
|
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(),
|
id: integer("id").primaryKey(),
|
||||||
userId: integer("user_id")
|
userId: integer("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -39,8 +48,14 @@ export const userClientChallenge = sqliteTable("user_client_challenge", {
|
|||||||
clientId: integer("client_id")
|
clientId: integer("client_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => client.id),
|
.references(() => client.id),
|
||||||
answer: text("challenge").notNull().unique(), // Base64
|
answer: text("answer").notNull().unique(), // Base64
|
||||||
allowedIp: text("allowed_ip").notNull(),
|
allowedIp: text("allowed_ip").notNull(),
|
||||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).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],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { sqliteTable, text, integer, foreignKey } from "drizzle-orm/sqlite-core";
|
import { sqliteTable, text, integer, foreignKey } from "drizzle-orm/sqlite-core";
|
||||||
|
import { hsk } from "./hsk";
|
||||||
import { mek } from "./mek";
|
import { mek } from "./mek";
|
||||||
import { user } from "./user";
|
import { user } from "./user";
|
||||||
|
|
||||||
@@ -12,7 +13,6 @@ export const directory = sqliteTable(
|
|||||||
"directory",
|
"directory",
|
||||||
{
|
{
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
|
||||||
parentId: integer("parent_id"),
|
parentId: integer("parent_id"),
|
||||||
userId: integer("user_id")
|
userId: integer("user_id")
|
||||||
.notNull()
|
.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(
|
export const file = sqliteTable(
|
||||||
"file",
|
"file",
|
||||||
{
|
{
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
path: text("path").notNull().unique(),
|
|
||||||
parentId: integer("parent_id").references(() => directory.id),
|
parentId: integer("parent_id").references(() => directory.id),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
|
||||||
userId: integer("user_id")
|
userId: integer("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id),
|
.references(() => user.id),
|
||||||
|
path: text("path").notNull().unique(),
|
||||||
mekVersion: integer("master_encryption_key_version").notNull(),
|
mekVersion: integer("master_encryption_key_version").notNull(),
|
||||||
encDek: text("encrypted_data_encryption_key").notNull().unique(), // Base64
|
encDek: text("encrypted_data_encryption_key").notNull().unique(), // Base64
|
||||||
dekVersion: integer("data_encryption_key_version", { mode: "timestamp_ms" }).notNull(),
|
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(),
|
contentType: text("content_type").notNull(),
|
||||||
encContentIv: text("encrypted_content_iv").notNull(), // Base64
|
encContentIv: text("encrypted_content_iv").notNull(), // Base64
|
||||||
encName: ciphertext("encrypted_name").notNull(),
|
encName: ciphertext("encrypted_name").notNull(),
|
||||||
|
encCreatedAt: ciphertext("encrypted_created_at"),
|
||||||
|
encLastModifiedAt: ciphertext("encrypted_last_modified_at").notNull(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
ref: foreignKey({
|
ref1: foreignKey({
|
||||||
columns: [t.userId, t.mekVersion],
|
columns: [t.userId, t.mekVersion],
|
||||||
foreignColumns: [mek.userId, mek.version],
|
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"),
|
||||||
|
});
|
||||||
|
|||||||
43
src/lib/server/db/schema/hsk.ts
Normal file
43
src/lib/server/db/schema/hsk.ts
Normal 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],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./client";
|
export * from "./client";
|
||||||
export * from "./file";
|
export * from "./file";
|
||||||
|
export * from "./hsk";
|
||||||
export * from "./mek";
|
export * from "./mek";
|
||||||
export * from "./token";
|
export * from "./session";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ export const mek = sqliteTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id),
|
.references(() => user.id),
|
||||||
version: integer("version").notNull(),
|
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(),
|
state: text("state", { enum: ["active", "retired", "dead"] }).notNull(),
|
||||||
retiredAt: integer("retired_at", { mode: "timestamp_ms" }),
|
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(
|
export const clientMek = sqliteTable(
|
||||||
"client_master_encryption_key",
|
"client_master_encryption_key",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,31 +2,34 @@ import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core";
|
|||||||
import { client } from "./client";
|
import { client } from "./client";
|
||||||
import { user } from "./user";
|
import { user } from "./user";
|
||||||
|
|
||||||
export const refreshToken = sqliteTable(
|
export const session = sqliteTable(
|
||||||
"refresh_token",
|
"session",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").notNull().primaryKey(),
|
||||||
userId: integer("user_id")
|
userId: integer("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id),
|
.references(() => user.id),
|
||||||
clientId: integer("client_id").references(() => client.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) => ({
|
(t) => ({
|
||||||
unq: unique().on(t.userId, t.clientId),
|
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(),
|
id: integer("id").primaryKey(),
|
||||||
refreshTokenId: text("refresh_token_id")
|
sessionId: text("session_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => refreshToken.id),
|
.references(() => session.id)
|
||||||
|
.unique(),
|
||||||
clientId: integer("client_id")
|
clientId: integer("client_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => client.id),
|
.references(() => client.id),
|
||||||
answer: text("challenge").notNull().unique(), // Base64
|
answer: text("answer").notNull().unique(), // Base64
|
||||||
allowedIp: text("allowed_ip").notNull(),
|
allowedIp: text("allowed_ip").notNull(),
|
||||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false),
|
|
||||||
});
|
});
|
||||||
@@ -4,4 +4,5 @@ export const user = sqliteTable("user", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
email: text("email").notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
password: text("password").notNull(),
|
password: text("password").notNull(),
|
||||||
|
nickname: text("nickname").notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
128
src/lib/server/db/session.ts
Normal file
128
src/lib/server/db/session.ts
Normal 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()));
|
||||||
|
};
|
||||||
@@ -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();
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,20 @@ import { eq } from "drizzle-orm";
|
|||||||
import db from "./drizzle";
|
import db from "./drizzle";
|
||||||
import { user } from "./schema";
|
import { user } from "./schema";
|
||||||
|
|
||||||
export const getUserByEmail = async (email: string) => {
|
export const getUser = async (userId: number) => {
|
||||||
const users = await db.select().from(user).where(eq(user.email, email)).execute();
|
const users = await db.select().from(user).where(eq(user.id, userId)).limit(1);
|
||||||
return users[0] ?? null;
|
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));
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,19 +3,18 @@ import { building } from "$app/environment";
|
|||||||
import { env } from "$env/dynamic/private";
|
import { env } from "$env/dynamic/private";
|
||||||
|
|
||||||
if (!building) {
|
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 {
|
export default {
|
||||||
databaseUrl: env.DATABASE_URL || "local.db",
|
databaseUrl: env.DATABASE_URL || "local.db",
|
||||||
jwt: {
|
session: {
|
||||||
secret: env.JWT_SECRET,
|
secret: env.SESSION_SECRET!,
|
||||||
accessExp: ms(env.JWT_ACCESS_TOKEN_EXPIRES || "5m"),
|
exp: ms(env.SESSION_EXPIRES || "14d"),
|
||||||
refreshExp: ms(env.JWT_REFRESH_TOKEN_EXPIRES || "14d"),
|
|
||||||
},
|
},
|
||||||
challenge: {
|
challenge: {
|
||||||
userClientExp: ms(env.USER_CLIENT_CHALLENGE_EXPIRES || "5m"),
|
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",
|
libraryPath: env.LIBRARY_PATH || "library",
|
||||||
};
|
};
|
||||||
|
|||||||
34
src/lib/server/middlewares/authenticate.ts
Normal file
34
src/lib/server/middlewares/authenticate.ts
Normal 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;
|
||||||
2
src/lib/server/middlewares/index.ts
Normal file
2
src/lib/server/middlewares/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as authenticate } from "./authenticate";
|
||||||
|
export { default as setAgentInfo } from "./setAgentInfo";
|
||||||
18
src/lib/server/middlewares/setAgentInfo.ts
Normal file
18
src/lib/server/middlewares/setAgentInfo.ts
Normal 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;
|
||||||
@@ -1,90 +1,127 @@
|
|||||||
import { error, type Cookies } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
import jwt from "jsonwebtoken";
|
|
||||||
import { getUserClient } from "$lib/server/db/client";
|
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 env from "$lib/server/loadenv";
|
||||||
|
import { issueSessionId, verifySessionId } from "$lib/server/modules/crypto";
|
||||||
|
|
||||||
type TokenPayload =
|
interface Session {
|
||||||
| {
|
sessionId: string;
|
||||||
type: "access";
|
|
||||||
userId: number;
|
userId: number;
|
||||||
clientId?: number;
|
clientId?: number;
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: "refresh";
|
|
||||||
jti: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export enum TokenError {
|
interface ClientSession extends Session {
|
||||||
EXPIRED,
|
clientId: number;
|
||||||
INVALID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
export const startSession = async (userId: number, ip: string, userAgent: string) => {
|
||||||
return jwt.sign(payload, env.jwt.secret, {
|
const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret);
|
||||||
expiresIn: (payload.type === "access" ? env.jwt.accessExp : env.jwt.refreshExp) / 1000,
|
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 {
|
try {
|
||||||
return jwt.verify(token, env.jwt.secret) as TokenPayload;
|
const { userId, clientId } = await refreshSession(sessionId, ip, userAgent);
|
||||||
} 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");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: tokenPayload.userId,
|
id: sessionId,
|
||||||
clientId: tokenPayload.clientId,
|
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(
|
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",
|
requiredPermission: "pendingClient",
|
||||||
): Promise<{ userId: number; clientId: number }>;
|
): Promise<ClientSession>;
|
||||||
|
|
||||||
export async function authorize(
|
export async function authorize(
|
||||||
cookies: Cookies,
|
locals: App.Locals,
|
||||||
requiredPermission: "activeClient",
|
requiredPermission: "activeClient",
|
||||||
): Promise<{ userId: number; clientId: number }>;
|
): Promise<ClientSession>;
|
||||||
|
|
||||||
export async function authorize(
|
export async function authorize(
|
||||||
cookies: Cookies,
|
locals: App.Locals,
|
||||||
requiredPermission: Permission,
|
requiredPermission: "any" | "notClient" | "anyClient" | "pendingClient" | "activeClient",
|
||||||
): Promise<{ userId: number; clientId?: number }> {
|
): Promise<Session> {
|
||||||
const tokenPayload = authenticate(cookies);
|
if (!locals.session) {
|
||||||
const { userId, clientId } = tokenPayload;
|
error(500, "Unauthenticated");
|
||||||
const userClient = clientId ? await getUserClient(userId, clientId) : undefined;
|
}
|
||||||
|
|
||||||
|
const { id: sessionId, userId, clientId } = locals.session;
|
||||||
|
|
||||||
switch (requiredPermission) {
|
switch (requiredPermission) {
|
||||||
case "pendingClient":
|
case "any":
|
||||||
if (!userClient || userClient.state !== "pending") {
|
break;
|
||||||
|
case "notClient":
|
||||||
|
if (clientId) {
|
||||||
error(403, "Forbidden");
|
error(403, "Forbidden");
|
||||||
}
|
}
|
||||||
return tokenPayload;
|
break;
|
||||||
case "activeClient":
|
case "anyClient":
|
||||||
if (!userClient || userClient.state !== "active") {
|
if (!clientId) {
|
||||||
error(403, "Forbidden");
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
import { promisify } from "util";
|
||||||
|
|
||||||
const makePubKeyToPem = (pubKey: string) =>
|
const makePubKeyToPem = (pubKey: string) =>
|
||||||
@@ -34,3 +42,26 @@ export const generateChallenge = async (length: number, encPubKey: string) => {
|
|||||||
const challenge = encryptAsymmetric(answer, encPubKey);
|
const challenge = encryptAsymmetric(answer, encPubKey);
|
||||||
return { answer, challenge };
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const verifyClientEncMekSig = async (
|
|||||||
) => {
|
) => {
|
||||||
const userClient = await getUserClientWithDetails(userId, clientId);
|
const userClient = await getUserClientWithDetails(userId, clientId);
|
||||||
if (!userClient) {
|
if (!userClient) {
|
||||||
error(500, "Invalid access token");
|
error(500, "Invalid session id");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = JSON.stringify({ version, key: encMek });
|
const data = JSON.stringify({ version, key: encMek });
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
import { z } from "zod";
|
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({
|
export const loginRequest = z.object({
|
||||||
email: z.string().email().nonempty(),
|
email: z.string().email().nonempty(),
|
||||||
password: z.string().trim().nonempty(),
|
password: z.string().trim().nonempty(),
|
||||||
});
|
});
|
||||||
export type LoginRequest = z.infer<typeof loginRequest>;
|
export type LoginRequest = z.infer<typeof loginRequest>;
|
||||||
|
|
||||||
export const tokenUpgradeRequest = z.object({
|
export const sessionUpgradeRequest = z.object({
|
||||||
encPubKey: z.string().base64().nonempty(),
|
encPubKey: z.string().base64().nonempty(),
|
||||||
sigPubKey: 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(),
|
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(),
|
answer: z.string().base64().nonempty(),
|
||||||
answerSig: z.string().base64().nonempty(),
|
answerSig: z.string().base64().nonempty(),
|
||||||
});
|
});
|
||||||
export type TokenUpgradeVerifyRequest = z.infer<typeof tokenUpgradeVerifyRequest>;
|
export type SessionUpgradeVerifyRequest = z.infer<typeof sessionUpgradeVerifyRequest>;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
export const directoryInfoResponse = z.object({
|
export const directoryInfoResponse = z.object({
|
||||||
metadata: z
|
metadata: z
|
||||||
.object({
|
.object({
|
||||||
createdAt: z.string().datetime(),
|
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||||
mekVersion: z.number().int().positive(),
|
mekVersion: z.number().int().positive(),
|
||||||
dek: z.string().base64().nonempty(),
|
dek: z.string().base64().nonempty(),
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
@@ -16,6 +16,11 @@ export const directoryInfoResponse = z.object({
|
|||||||
});
|
});
|
||||||
export type DirectoryInfoResponse = z.infer<typeof directoryInfoResponse>;
|
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({
|
export const directoryRenameRequest = z.object({
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
name: z.string().base64().nonempty(),
|
name: z.string().base64().nonempty(),
|
||||||
@@ -24,7 +29,7 @@ export const directoryRenameRequest = z.object({
|
|||||||
export type DirectoryRenameRequest = z.infer<typeof directoryRenameRequest>;
|
export type DirectoryRenameRequest = z.infer<typeof directoryRenameRequest>;
|
||||||
|
|
||||||
export const directoryCreateRequest = z.object({
|
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(),
|
mekVersion: z.number().int().positive(),
|
||||||
dek: z.string().base64().nonempty(),
|
dek: z.string().base64().nonempty(),
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import mime from "mime";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const fileInfoResponse = z.object({
|
export const fileInfoResponse = z.object({
|
||||||
createdAt: z.string().datetime(),
|
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||||
mekVersion: z.number().int().positive(),
|
mekVersion: z.number().int().positive(),
|
||||||
dek: z.string().base64().nonempty(),
|
dek: z.string().base64().nonempty(),
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
@@ -13,6 +13,10 @@ export const fileInfoResponse = z.object({
|
|||||||
contentIv: z.string().base64().nonempty(),
|
contentIv: z.string().base64().nonempty(),
|
||||||
name: z.string().base64().nonempty(),
|
name: z.string().base64().nonempty(),
|
||||||
nameIv: 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>;
|
export type FileInfoResponse = z.infer<typeof fileInfoResponse>;
|
||||||
|
|
||||||
@@ -23,11 +27,24 @@ export const fileRenameRequest = z.object({
|
|||||||
});
|
});
|
||||||
export type FileRenameRequest = z.infer<typeof fileRenameRequest>;
|
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({
|
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(),
|
mekVersion: z.number().int().positive(),
|
||||||
dek: z.string().base64().nonempty(),
|
dek: z.string().base64().nonempty(),
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
|
hskVersion: z.number().int().positive(),
|
||||||
|
contentHmac: z.string().base64().nonempty(),
|
||||||
contentType: z
|
contentType: z
|
||||||
.string()
|
.string()
|
||||||
.nonempty()
|
.nonempty()
|
||||||
@@ -35,5 +52,9 @@ export const fileUploadRequest = z.object({
|
|||||||
contentIv: z.string().base64().nonempty(),
|
contentIv: z.string().base64().nonempty(),
|
||||||
name: z.string().base64().nonempty(),
|
name: z.string().base64().nonempty(),
|
||||||
nameIv: 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>;
|
export type FileUploadRequest = z.infer<typeof fileUploadRequest>;
|
||||||
|
|||||||
19
src/lib/server/schemas/hsk.ts
Normal file
19
src/lib/server/schemas/hsk.ts
Normal 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>;
|
||||||
@@ -2,4 +2,6 @@ export * from "./auth";
|
|||||||
export * from "./client";
|
export * from "./client";
|
||||||
export * from "./directory";
|
export * from "./directory";
|
||||||
export * from "./file";
|
export * from "./file";
|
||||||
|
export * from "./hsk";
|
||||||
export * from "./mek";
|
export * from "./mek";
|
||||||
|
export * from "./user";
|
||||||
|
|||||||
12
src/lib/server/schemas/user.ts
Normal file
12
src/lib/server/schemas/user.ts
Normal 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>;
|
||||||
@@ -1,164 +1,121 @@
|
|||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
import argon2 from "argon2";
|
import argon2 from "argon2";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client";
|
import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client";
|
||||||
import { getUserByEmail } from "$lib/server/db/user";
|
import { IntegrityError } from "$lib/server/db/error";
|
||||||
import env from "$lib/server/loadenv";
|
|
||||||
import {
|
import {
|
||||||
getRefreshToken,
|
upgradeSession,
|
||||||
registerRefreshToken,
|
deleteSession,
|
||||||
rotateRefreshToken,
|
deleteAllOtherSessions,
|
||||||
upgradeRefreshToken,
|
registerSessionUpgradeChallenge,
|
||||||
revokeRefreshToken,
|
consumeSessionUpgradeChallenge,
|
||||||
registerTokenUpgradeChallenge,
|
} from "$lib/server/db/session";
|
||||||
getTokenUpgradeChallenge,
|
import { getUser, getUserByEmail, setUserPassword } from "$lib/server/db/user";
|
||||||
markTokenUpgradeChallengeAsUsed,
|
import env from "$lib/server/loadenv";
|
||||||
} from "$lib/server/db/token";
|
import { startSession } from "$lib/server/modules/auth";
|
||||||
import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth";
|
|
||||||
import { verifySignature, generateChallenge } from "$lib/server/modules/crypto";
|
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) => {
|
const verifyPassword = async (hash: string, password: string) => {
|
||||||
return await argon2.verify(hash, password);
|
return await argon2.verify(hash, password);
|
||||||
};
|
};
|
||||||
|
|
||||||
const issueAccessToken = (userId: number, clientId?: number) => {
|
export const changePassword = async (
|
||||||
return issueToken({ type: "access", userId, clientId });
|
userId: number,
|
||||||
};
|
sessionId: string,
|
||||||
|
oldPassword: string,
|
||||||
const issueRefreshToken = async (userId: number, clientId?: number) => {
|
newPassword: string,
|
||||||
const jti = uuidv4();
|
) => {
|
||||||
const token = issueToken({ type: "refresh", jti });
|
if (oldPassword === newPassword) {
|
||||||
|
error(400, "Same passwords");
|
||||||
if (!(await registerRefreshToken(userId, clientId ?? null, jti))) {
|
} else if (newPassword.length < 8) {
|
||||||
error(403, "Already logged in");
|
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);
|
const user = await getUserByEmail(email);
|
||||||
if (!user || !(await verifyPassword(user.password, password))) {
|
if (!user || !(await verifyPassword(user.password, password))) {
|
||||||
error(401, "Invalid email or password");
|
error(401, "Invalid email or password");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
try {
|
||||||
accessToken: issueAccessToken(user.id),
|
return { sessionIdSigned: await startSession(user.id, ip, userAgent) };
|
||||||
refreshToken: await issueRefreshToken(user.id),
|
} catch (e) {
|
||||||
};
|
if (e instanceof IntegrityError && e.message === "Session already exists") {
|
||||||
};
|
error(403, "Already logged in");
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
throw e;
|
||||||
const tokenData = await getRefreshToken(tokenPayload.jti);
|
|
||||||
if (!tokenData) {
|
|
||||||
error(500, "Refresh token not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
jti: tokenPayload.jti,
|
|
||||||
userId: tokenData.userId,
|
|
||||||
clientId: tokenData.clientId ?? undefined,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logout = async (refreshToken: string) => {
|
export const logout = async (sessionId: string) => {
|
||||||
const { jti } = await verifyRefreshToken(refreshToken);
|
await deleteSession(sessionId);
|
||||||
await revokeRefreshToken(jti);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const refreshToken = async (refreshToken: string) => {
|
export const createSessionUpgradeChallenge = async (
|
||||||
const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken);
|
sessionId: string,
|
||||||
const newJti = uuidv4();
|
userId: number,
|
||||||
|
|
||||||
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,
|
|
||||||
ip: string,
|
ip: string,
|
||||||
encPubKey: string,
|
encPubKey: string,
|
||||||
sigPubKey: string,
|
sigPubKey: string,
|
||||||
) => {
|
) => {
|
||||||
const { jti, userId, clientId } = await verifyRefreshToken(refreshToken);
|
|
||||||
if (clientId) {
|
|
||||||
error(403, "Forbidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = await getClientByPubKeys(encPubKey, sigPubKey);
|
const client = await getClientByPubKeys(encPubKey, sigPubKey);
|
||||||
const userClient = client ? await getUserClient(userId, client.id) : undefined;
|
const userClient = client ? await getUserClient(userId, client.id) : undefined;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
error(401, "Invalid public key(s)");
|
error(401, "Invalid public key(s)");
|
||||||
} else if (!userClient || userClient.state === "challenging") {
|
} 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 (
|
export const verifySessionUpgradeChallenge = async (
|
||||||
refreshToken: string,
|
sessionId: string,
|
||||||
ip: string,
|
ip: string,
|
||||||
answer: string,
|
answer: string,
|
||||||
answerSig: string,
|
answerSig: string,
|
||||||
) => {
|
) => {
|
||||||
const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken);
|
const challenge = await consumeSessionUpgradeChallenge(sessionId, answer, ip);
|
||||||
if (clientId) {
|
|
||||||
error(403, "Forbidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
const challenge = await getTokenUpgradeChallenge(answer, ip);
|
|
||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
error(401, "Invalid challenge answer");
|
error(403, "Invalid challenge answer");
|
||||||
} else if (challenge.refreshTokenId !== oldJti) {
|
|
||||||
error(403, "Forbidden");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await getClient(challenge.clientId);
|
const client = await getClient(challenge.clientId);
|
||||||
if (!client) {
|
if (!client) {
|
||||||
error(500, "Invalid challenge answer");
|
error(500, "Invalid challenge answer");
|
||||||
} else if (!verifySignature(Buffer.from(answer, "base64"), answerSig, client.sigPubKey)) {
|
} 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);
|
try {
|
||||||
|
await upgradeSession(sessionId, client.id);
|
||||||
const newJti = uuidv4();
|
} catch (e) {
|
||||||
if (!(await upgradeRefreshToken(oldJti, newJti, client.id))) {
|
if (e instanceof IntegrityError && e.message === "Session not found") {
|
||||||
error(500, "Refresh token not found");
|
error(500, "Invalid challenge answer");
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
accessToken: issueAccessToken(userId, client.id),
|
|
||||||
refreshToken: issueToken({ type: "refresh", jti: newJti }),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ import {
|
|||||||
createClient,
|
createClient,
|
||||||
getClient,
|
getClient,
|
||||||
getClientByPubKeys,
|
getClientByPubKeys,
|
||||||
countClientByPubKey,
|
|
||||||
createUserClient,
|
createUserClient,
|
||||||
getAllUserClients,
|
getAllUserClients,
|
||||||
getUserClient,
|
getUserClient,
|
||||||
setUserClientStateToPending,
|
setUserClientStateToPending,
|
||||||
registerUserClientChallenge,
|
registerUserClientChallenge,
|
||||||
getUserClientChallenge,
|
consumeUserClientChallenge,
|
||||||
markUserClientChallengeAsUsed,
|
|
||||||
} from "$lib/server/db/client";
|
} from "$lib/server/db/client";
|
||||||
|
import { IntegrityError } from "$lib/server/db/error";
|
||||||
import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto";
|
import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto";
|
||||||
import { isInitialMekNeeded } from "$lib/server/modules/mek";
|
import { isInitialMekNeeded } from "$lib/server/modules/mek";
|
||||||
import env from "$lib/server/loadenv";
|
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 expiresAt = () => new Date(Date.now() + env.challenge.userClientExp);
|
||||||
|
|
||||||
const createUserClientChallenge = async (
|
const createUserClientChallenge = async (
|
||||||
userId: number,
|
|
||||||
ip: string,
|
ip: string,
|
||||||
|
userId: number,
|
||||||
clientId: number,
|
clientId: number,
|
||||||
encPubKey: string,
|
encPubKey: string,
|
||||||
) => {
|
) => {
|
||||||
@@ -45,45 +44,34 @@ export const registerUserClient = async (
|
|||||||
encPubKey: string,
|
encPubKey: string,
|
||||||
sigPubKey: string,
|
sigPubKey: string,
|
||||||
) => {
|
) => {
|
||||||
let clientId;
|
|
||||||
|
|
||||||
const client = await getClientByPubKeys(encPubKey, sigPubKey);
|
const client = await getClientByPubKeys(encPubKey, sigPubKey);
|
||||||
if (client) {
|
if (client) {
|
||||||
const userClient = await getUserClient(userId, client.id);
|
try {
|
||||||
if (userClient) {
|
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");
|
error(409, "Client already registered");
|
||||||
}
|
}
|
||||||
|
throw e;
|
||||||
await createUserClient(userId, client.id);
|
}
|
||||||
clientId = client.id;
|
|
||||||
} else {
|
} 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)");
|
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 (
|
export const verifyUserClient = async (
|
||||||
@@ -92,20 +80,29 @@ export const verifyUserClient = async (
|
|||||||
answer: string,
|
answer: string,
|
||||||
answerSig: string,
|
answerSig: string,
|
||||||
) => {
|
) => {
|
||||||
const challenge = await getUserClientChallenge(answer, ip);
|
const challenge = await consumeUserClientChallenge(userId, answer, ip);
|
||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
error(401, "Invalid challenge answer");
|
error(403, "Invalid challenge answer");
|
||||||
} else if (challenge.userId !== userId) {
|
|
||||||
error(403, "Forbidden");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await getClient(challenge.clientId);
|
const client = await getClient(challenge.clientId);
|
||||||
if (!client) {
|
if (!client) {
|
||||||
error(500, "Invalid challenge answer");
|
error(500, "Invalid challenge answer");
|
||||||
} else if (!verifySignature(Buffer.from(answer, "base64"), answerSig, client.sigPubKey)) {
|
} 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, client.id);
|
||||||
await setUserClientStateToPending(userId, challenge.clientId);
|
};
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,44 +1,15 @@
|
|||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
import { unlink } from "fs/promises";
|
import { unlink } from "fs/promises";
|
||||||
|
import { IntegrityError } from "$lib/server/db/error";
|
||||||
import {
|
import {
|
||||||
|
registerDirectory,
|
||||||
getAllDirectoriesByParent,
|
getAllDirectoriesByParent,
|
||||||
registerNewDirectory,
|
|
||||||
getDirectory,
|
getDirectory,
|
||||||
setDirectoryEncName,
|
setDirectoryEncName,
|
||||||
unregisterDirectory,
|
unregisterDirectory,
|
||||||
getAllFilesByParent,
|
getAllFilesByParent,
|
||||||
type NewDirectoryParams,
|
type NewDirectoryParams,
|
||||||
} from "$lib/server/db/file";
|
} 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) => {
|
export const getDirectoryInformation = async (userId: number, directoryId: "root" | number) => {
|
||||||
const directory = directoryId !== "root" ? await getDirectory(userId, directoryId) : undefined;
|
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 directories = await getAllDirectoriesByParent(userId, directoryId);
|
||||||
const files = await getAllFilesByParent(userId, directoryId);
|
const files = await getAllFilesByParent(userId, directoryId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metadata: directory && {
|
metadata: directory && {
|
||||||
createdAt: directory.createdAt,
|
parentId: directory.parentId ?? ("root" as const),
|
||||||
mekVersion: directory.mekVersion,
|
mekVersion: directory.mekVersion,
|
||||||
encDek: directory.encDek,
|
encDek: directory.encDek,
|
||||||
dekVersion: directory.dekVersion,
|
dekVersion: directory.dekVersion,
|
||||||
@@ -62,19 +32,57 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createDirectory = async (params: NewDirectoryParams) => {
|
export const deleteDirectory = async (userId: number, directoryId: number) => {
|
||||||
const activeMekVersion = await getActiveMekVersion(params.userId);
|
try {
|
||||||
if (activeMekVersion === null) {
|
const files = await unregisterDirectory(userId, directoryId);
|
||||||
error(500, "Invalid MEK version");
|
return {
|
||||||
} else if (activeMekVersion !== params.mekVersion) {
|
files: files.map(({ id, path }) => {
|
||||||
error(400, "Invalid MEK version");
|
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 oneMinuteAgo = new Date(Date.now() - 60 * 1000);
|
||||||
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
|
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
|
||||||
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
|
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
|
||||||
error(400, "Invalid DEK version");
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,43 +1,50 @@
|
|||||||
import { error } from "@sveltejs/kit";
|
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 { mkdir, stat, unlink } from "fs/promises";
|
||||||
import { dirname } from "path";
|
import { dirname } from "path";
|
||||||
|
import { Readable } from "stream";
|
||||||
|
import { pipeline } from "stream/promises";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { IntegrityError } from "$lib/server/db/error";
|
||||||
import {
|
import {
|
||||||
registerNewFile,
|
registerFile,
|
||||||
|
getAllFileIdsByContentHmac,
|
||||||
getFile,
|
getFile,
|
||||||
setFileEncName,
|
setFileEncName,
|
||||||
unregisterFile,
|
unregisterFile,
|
||||||
type NewFileParams,
|
type NewFileParams,
|
||||||
} from "$lib/server/db/file";
|
} from "$lib/server/db/file";
|
||||||
import { getActiveMekVersion } from "$lib/server/db/mek";
|
|
||||||
import env from "$lib/server/loadenv";
|
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);
|
const file = await getFile(userId, fileId);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
error(404, "Invalid file id");
|
error(404, "Invalid file id");
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = await unregisterFile(userId, fileId);
|
return {
|
||||||
if (!path) {
|
parentId: file.parentId ?? ("root" as const),
|
||||||
error(500, "Invalid file id");
|
mekVersion: file.mekVersion,
|
||||||
}
|
encDek: file.encDek,
|
||||||
|
dekVersion: file.dekVersion,
|
||||||
unlink(path); // Intended
|
contentType: file.contentType,
|
||||||
|
encContentIv: file.encContentIv,
|
||||||
|
encName: file.encName,
|
||||||
|
encCreatedAt: file.encCreatedAt,
|
||||||
|
encLastModifiedAt: file.encLastModifiedAt,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const convertToReadableStream = (readStream: ReadStream) => {
|
export const deleteFile = async (userId: number, fileId: number) => {
|
||||||
return new ReadableStream<Uint8Array>({
|
try {
|
||||||
start: (controller) => {
|
const filePath = await unregisterFile(userId, fileId);
|
||||||
readStream.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk as Buffer)));
|
unlink(filePath); // Intended
|
||||||
readStream.on("end", () => controller.close());
|
} catch (e) {
|
||||||
readStream.on("error", (e) => controller.error(e));
|
if (e instanceof IntegrityError && e.message === "File not found") {
|
||||||
},
|
error(404, "Invalid file id");
|
||||||
cancel: () => {
|
}
|
||||||
readStream.destroy();
|
throw e;
|
||||||
},
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFileStream = async (userId: number, fileId: number) => {
|
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);
|
const { size } = await stat(file.path);
|
||||||
return {
|
return {
|
||||||
encContentStream: convertToReadableStream(createReadStream(file.path)),
|
encContentStream: Readable.toWeb(createReadStream(file.path)),
|
||||||
encContentSize: size,
|
encContentSize: size,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -60,49 +67,27 @@ export const renameFile = async (
|
|||||||
newEncName: string,
|
newEncName: string,
|
||||||
newEncNameIv: string,
|
newEncNameIv: string,
|
||||||
) => {
|
) => {
|
||||||
const file = await getFile(userId, fileId);
|
try {
|
||||||
if (!file) {
|
await setFileEncName(userId, fileId, dekVersion, newEncName, newEncNameIv);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof IntegrityError) {
|
||||||
|
if (e.message === "File not found") {
|
||||||
error(404, "Invalid file id");
|
error(404, "Invalid file id");
|
||||||
} else if (file.dekVersion.getTime() !== dekVersion.getTime()) {
|
} else if (e.message === "Invalid DEK version") {
|
||||||
error(400, "Invalid DEK version");
|
error(400, "Invalid DEK version");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!(await setFileEncName(userId, fileId, dekVersion, newEncName, newEncNameIv))) {
|
throw e;
|
||||||
error(500, "Invalid file id or DEK version");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFileInformation = async (userId: number, fileId: number) => {
|
export const scanDuplicateFiles = async (
|
||||||
const file = await getFile(userId, fileId);
|
userId: number,
|
||||||
if (!file) {
|
hskVersion: number,
|
||||||
error(404, "Invalid file id");
|
contentHmac: string,
|
||||||
}
|
) => {
|
||||||
|
const fileIds = await getAllFileIdsByContentHmac(userId, hskVersion, contentHmac);
|
||||||
return {
|
return { files: fileIds.map(({ id }) => id) };
|
||||||
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)),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeUnlink = async (path: string) => {
|
const safeUnlink = async (path: string) => {
|
||||||
@@ -111,18 +96,11 @@ const safeUnlink = async (path: string) => {
|
|||||||
|
|
||||||
export const uploadFile = async (
|
export const uploadFile = async (
|
||||||
params: Omit<NewFileParams, "path">,
|
params: Omit<NewFileParams, "path">,
|
||||||
encContentStream: ReadableStream<Uint8Array>,
|
encContentStream: Readable,
|
||||||
) => {
|
) => {
|
||||||
const activeMekVersion = await getActiveMekVersion(params.userId);
|
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
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 oneMinuteLater = new Date(Date.now() + 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");
|
error(400, "Invalid DEK version");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,15 +108,17 @@ export const uploadFile = async (
|
|||||||
await mkdir(dirname(path), { recursive: true });
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await encContentStream.pipeTo(
|
await pipeline(encContentStream, createWriteStream(path, { flags: "wx", mode: 0o600 }));
|
||||||
convertToWritableStream(createWriteStream(path, { flags: "wx", mode: 0o600 })),
|
await registerFile({
|
||||||
);
|
|
||||||
await registerNewFile({
|
|
||||||
...params,
|
...params,
|
||||||
path,
|
path,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await safeUnlink(path);
|
await safeUnlink(path);
|
||||||
|
|
||||||
|
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
|
||||||
|
error(400, "Invalid MEK version");
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
31
src/lib/server/services/hsk.ts
Normal file
31
src/lib/server/services/hsk.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
import { setUserClientStateToActive } from "$lib/server/db/client";
|
import { setUserClientStateToActive } from "$lib/server/db/client";
|
||||||
|
import { IntegrityError } from "$lib/server/db/error";
|
||||||
import { registerInitialMek, getAllValidClientMeks } from "$lib/server/db/mek";
|
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) => {
|
export const getClientMekList = async (userId: number, clientId: number) => {
|
||||||
const clientMeks = await getAllValidClientMeks(userId, clientId);
|
const clientMeks = await getAllValidClientMeks(userId, clientId);
|
||||||
@@ -21,12 +22,17 @@ export const registerInitialActiveMek = async (
|
|||||||
encMek: string,
|
encMek: string,
|
||||||
encMekSig: string,
|
encMekSig: string,
|
||||||
) => {
|
) => {
|
||||||
if (!(await isInitialMekNeeded(userId))) {
|
if (!(await verifyClientEncMekSig(userId, createdBy, 1, encMek, encMekSig))) {
|
||||||
error(409, "Initial MEK already registered");
|
|
||||||
} else if (!(await verifyClientEncMekSig(userId, createdBy, 1, encMek, encMekSig))) {
|
|
||||||
error(400, "Invalid signature");
|
error(400, "Invalid signature");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await registerInitialMek(userId, createdBy, encMek, encMekSig);
|
await registerInitialMek(userId, createdBy, encMek, encMekSig);
|
||||||
await setUserClientStateToActive(userId, createdBy);
|
await setUserClientStateToActive(userId, createdBy);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof IntegrityError && e.message === "MEK already registered") {
|
||||||
|
error(409, "Initial MEK already registered");
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
15
src/lib/server/services/user.ts
Normal file
15
src/lib/server/services/user.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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 {
|
import type {
|
||||||
TokenUpgradeRequest,
|
SessionUpgradeRequest,
|
||||||
TokenUpgradeResponse,
|
SessionUpgradeResponse,
|
||||||
TokenUpgradeVerifyRequest,
|
SessionUpgradeVerifyRequest,
|
||||||
} from "$lib/server/schemas";
|
} from "$lib/server/schemas";
|
||||||
|
|
||||||
export const requestTokenUpgrade = async (
|
export const requestSessionUpgrade = async (
|
||||||
encryptKeyBase64: string,
|
encryptKeyBase64: string,
|
||||||
decryptKey: CryptoKey,
|
decryptKey: CryptoKey,
|
||||||
verifyKeyBase64: string,
|
verifyKeyBase64: string,
|
||||||
signKey: CryptoKey,
|
signKey: CryptoKey,
|
||||||
) => {
|
) => {
|
||||||
let res = await fetch("/api/auth/upgradeToken", {
|
let res = await callPostApi<SessionUpgradeRequest>("/api/auth/upgradeSession", {
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
encPubKey: encryptKeyBase64,
|
encPubKey: encryptKeyBase64,
|
||||||
sigPubKey: verifyKeyBase64,
|
sigPubKey: verifyKeyBase64,
|
||||||
} satisfies TokenUpgradeRequest),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
|
|
||||||
const { challenge }: TokenUpgradeResponse = await res.json();
|
const { challenge }: SessionUpgradeResponse = await res.json();
|
||||||
const answer = await decryptChallenge(challenge, decryptKey);
|
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", {
|
res = await callPostApi<SessionUpgradeVerifyRequest>("/api/auth/upgradeSession/verify", {
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
answer: encodeToBase64(answer),
|
answer: encodeToBase64(answer),
|
||||||
answerSig: encodeToBase64(answerSig),
|
answerSig: encodeToBase64(answerSig),
|
||||||
} satisfies TokenUpgradeVerifyRequest),
|
|
||||||
});
|
});
|
||||||
return res.ok;
|
return res.ok;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { storeMasterKeys } from "$lib/indexedDB";
|
|||||||
import {
|
import {
|
||||||
encodeToBase64,
|
encodeToBase64,
|
||||||
decryptChallenge,
|
decryptChallenge,
|
||||||
signMessage,
|
signMessageRSA,
|
||||||
unwrapMasterKey,
|
unwrapMasterKey,
|
||||||
verifyMasterKeyWrapped,
|
verifyMasterKeyWrapped,
|
||||||
} from "$lib/modules/crypto";
|
} from "$lib/modules/crypto";
|
||||||
@@ -29,7 +29,7 @@ export const requestClientRegistration = async (
|
|||||||
|
|
||||||
const { challenge }: ClientRegisterResponse = await res.json();
|
const { challenge }: ClientRegisterResponse = await res.json();
|
||||||
const answer = await decryptChallenge(challenge, decryptKey);
|
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", {
|
res = await callPostApi<ClientRegisterVerifyRequest>("/api/client/register/verify", {
|
||||||
answer: encodeToBase64(answer),
|
answer: encodeToBase64(answer),
|
||||||
|
|||||||
@@ -1,31 +1,49 @@
|
|||||||
import type { Writable } from "svelte/store";
|
import { writable, type Writable } from "svelte/store";
|
||||||
|
|
||||||
export type DirectoryInfo =
|
export interface FileUploadStatus {
|
||||||
| {
|
|
||||||
id: "root";
|
|
||||||
dataKey?: undefined;
|
|
||||||
dataKeyVersion?: undefined;
|
|
||||||
name?: undefined;
|
|
||||||
subDirectoryIds: number[];
|
|
||||||
fileIds: number[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
id: number;
|
|
||||||
dataKey: CryptoKey;
|
|
||||||
dataKeyVersion: Date;
|
|
||||||
name: string;
|
name: string;
|
||||||
subDirectoryIds: number[];
|
parentId: "root" | number;
|
||||||
fileIds: 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 {
|
export const isFileDownloading = (
|
||||||
id: number;
|
status: FileDownloadStatus["status"],
|
||||||
dataKey: CryptoKey;
|
): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => {
|
||||||
dataKeyVersion: Date;
|
return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
|
||||||
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>>();
|
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ export interface MasterKey {
|
|||||||
key: CryptoKey;
|
key: CryptoKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HmacSecret {
|
||||||
|
version: number;
|
||||||
|
state: "active";
|
||||||
|
secret: CryptoKey;
|
||||||
|
}
|
||||||
|
|
||||||
export const clientKeyStore = writable<ClientKeys | null>(null);
|
export const clientKeyStore = writable<ClientKeys | null>(null);
|
||||||
|
|
||||||
export const masterKeyStore = writable<Map<number, MasterKey> | null>(null);
|
export const masterKeyStore = writable<Map<number, MasterKey> | null>(null);
|
||||||
|
|
||||||
|
export const hmacSecretStore = writable<Map<number, HmacSecret> | null>(null);
|
||||||
|
|||||||
38
src/routes/(fullscreen)/auth/changePassword/+page.svelte
Normal file
38
src/routes/(fullscreen)/auth/changePassword/+page.svelte
Normal 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>
|
||||||
10
src/routes/(fullscreen)/auth/changePassword/service.ts
Normal file
10
src/routes/(fullscreen)/auth/changePassword/service.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import type { PageServerLoad } from "./$types";
|
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 redirectPath = url.searchParams.get("redirect") || "/home";
|
||||||
|
|
||||||
const accessToken = cookies.get("accessToken");
|
if (locals.session) {
|
||||||
if (accessToken) {
|
|
||||||
redirect(302, redirectPath);
|
redirect(302, redirectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { Button, TextButton } from "$lib/components/buttons";
|
import { Button, TextButton } from "$lib/components/buttons";
|
||||||
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
||||||
import { TextInput } from "$lib/components/inputs";
|
import { TextInput } from "$lib/components/inputs";
|
||||||
import { refreshToken } from "$lib/hooks";
|
|
||||||
import { clientKeyStore, masterKeyStore } from "$lib/stores";
|
import { clientKeyStore, masterKeyStore } from "$lib/stores";
|
||||||
import { requestLogin, requestTokenUpgrade, requestMasterKeyDownload } from "./service";
|
import { requestLogin, requestSessionUpgrade, requestMasterKeyDownload } from "./service";
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -25,7 +23,8 @@
|
|||||||
|
|
||||||
if (!$clientKeyStore) return await redirect("/key/generate");
|
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
|
// TODO: Multi-user support
|
||||||
|
|
||||||
@@ -42,13 +41,6 @@
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
const res = await refreshToken();
|
|
||||||
if (res.ok) {
|
|
||||||
await goto(data.redirectPath, { replaceState: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
|
import { callPostApi } from "$lib/hooks";
|
||||||
import { exportRSAKeyToBase64 } from "$lib/modules/crypto";
|
import { exportRSAKeyToBase64 } from "$lib/modules/crypto";
|
||||||
import type { LoginRequest } from "$lib/server/schemas";
|
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 { requestClientRegistration } from "$lib/services/key";
|
||||||
import type { ClientKeys } from "$lib/stores";
|
import type { ClientKeys } from "$lib/stores";
|
||||||
|
|
||||||
export { requestMasterKeyDownload } from "$lib/services/key";
|
export { requestMasterKeyDownload } from "$lib/services/key";
|
||||||
|
|
||||||
export const requestLogin = async (email: string, password: string) => {
|
export const requestLogin = async (email: string, password: string) => {
|
||||||
const res = await fetch("/api/auth/login", {
|
const res = await callPostApi<LoginRequest>("/api/auth/login", { email, password });
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email, password } satisfies LoginRequest),
|
|
||||||
});
|
|
||||||
return res.ok;
|
return res.ok;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requestTokenUpgrade = async ({
|
export const requestSessionUpgrade = async ({
|
||||||
encryptKey,
|
encryptKey,
|
||||||
decryptKey,
|
decryptKey,
|
||||||
signKey,
|
signKey,
|
||||||
@@ -23,12 +20,12 @@ export const requestTokenUpgrade = async ({
|
|||||||
}: ClientKeys) => {
|
}: ClientKeys) => {
|
||||||
const encryptKeyBase64 = await exportRSAKeyToBase64(encryptKey);
|
const encryptKeyBase64 = await exportRSAKeyToBase64(encryptKey);
|
||||||
const verifyKeyBase64 = await exportRSAKeyToBase64(verifyKey);
|
const verifyKeyBase64 = await exportRSAKeyToBase64(verifyKey);
|
||||||
if (await requestTokenUpgradeInternal(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) {
|
if (await requestSessionUpgradeInternal(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) {
|
if (await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) {
|
||||||
return await requestTokenUpgradeInternal(
|
return await requestSessionUpgradeInternal(
|
||||||
encryptKeyBase64,
|
encryptKeyBase64,
|
||||||
decryptKey,
|
decryptKey,
|
||||||
verifyKeyBase64,
|
verifyKeyBase64,
|
||||||
|
|||||||
@@ -1,66 +1,78 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FileSaver from "file-saver";
|
import FileSaver from "file-saver";
|
||||||
import heic2any from "heic2any";
|
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import type { Writable } from "svelte/store";
|
import { get, type Writable } from "svelte/store";
|
||||||
import { TopBar } from "$lib/components";
|
import { TopBar } from "$lib/components";
|
||||||
import { getFileInfo } from "$lib/modules/file";
|
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
||||||
import { masterKeyStore, type FileInfo } from "$lib/stores";
|
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
|
||||||
|
import DownloadStatus from "./DownloadStatus.svelte";
|
||||||
import { requestFileDownload } from "./service";
|
import { requestFileDownload } from "./service";
|
||||||
|
|
||||||
type ContentType = "image" | "video";
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let info: Writable<FileInfo | null> | undefined = $state();
|
let info: Writable<FileInfo | null> | undefined = $state();
|
||||||
let isDownloaded = $state(false);
|
|
||||||
|
|
||||||
let content: Blob | undefined = $state();
|
const downloadStatus = $derived(
|
||||||
let contentUrl: string | undefined = $state();
|
$fileDownloadStatusStore.find((statusStore) => {
|
||||||
let contentType: ContentType | undefined = $state();
|
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(() => {
|
$effect(() => {
|
||||||
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
||||||
isDownloaded = false;
|
isDownloadRequested = false;
|
||||||
|
viewerType = undefined;
|
||||||
content = undefined;
|
|
||||||
contentType = undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($info && !isDownloaded) {
|
if ($info && $info.dataKey && $info.contentIv) {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
isDownloaded = true;
|
if (!downloadStatus && !isDownloadRequested) {
|
||||||
|
isDownloadRequested = true;
|
||||||
if ($info.contentType.startsWith("image")) {
|
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => {
|
||||||
contentType = "image";
|
const blob = await updateViewer($info, buffer);
|
||||||
} else if ($info.contentType.startsWith("video")) {
|
if (!viewerType) {
|
||||||
contentType = "video";
|
FileSaver.saveAs(blob, $info.name);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
return () => {
|
if ($info && $downloadStatus?.status === "decrypted") {
|
||||||
if (contentUrl) {
|
untrack(() => !isDownloadRequested && updateViewer($info, $downloadStatus.result!));
|
||||||
URL.revokeObjectURL(contentUrl);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => () => fileBlobUrl && URL.revokeObjectURL(fileBlobUrl));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -69,23 +81,24 @@
|
|||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<TopBar title={$info?.name} />
|
<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)}
|
{#snippet viewerLoading(message: string)}
|
||||||
<div class="flex flex-grow items-center justify-center">
|
<div class="flex flex-grow items-center justify-center">
|
||||||
<p class="text-gray-500">{message}</p>
|
<p class="text-gray-500">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if $info && contentType === "image"}
|
{#if $info && viewerType === "image"}
|
||||||
{#if contentUrl}
|
{#if fileBlobUrl}
|
||||||
<img src={contentUrl} alt={$info.name} />
|
<img src={fileBlobUrl} alt={$info.name} />
|
||||||
{:else}
|
{:else}
|
||||||
{@render viewerLoading("이미지를 불러오고 있어요.")}
|
{@render viewerLoading("이미지를 불러오고 있어요.")}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if contentType === "video"}
|
{:else if viewerType === "video"}
|
||||||
{#if contentUrl}
|
{#if fileBlobUrl}
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
<video src={contentUrl} controls></video>
|
<video src={fileBlobUrl} controls></video>
|
||||||
{:else}
|
{:else}
|
||||||
{@render viewerLoading("비디오를 불러오고 있어요.")}
|
{@render viewerLoading("비디오를 불러오고 있어요.")}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { error } from "@sveltejs/kit";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const ssr = false; // Because of heic2any
|
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
export const load: PageLoad = async ({ params }) => {
|
||||||
const zodRes = z
|
const zodRes = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
33
src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte
Normal file
33
src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte
Normal 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}
|
||||||
@@ -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,
|
fileId: number,
|
||||||
fileEncryptedIv: string,
|
fileEncryptedIv: string,
|
||||||
dataKey: CryptoKey,
|
dataKey: CryptoKey,
|
||||||
) => {
|
) => {
|
||||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
const cache = await getFileCache(fileId);
|
||||||
const xhr = new XMLHttpRequest();
|
if (cache) return cache;
|
||||||
xhr.responseType = "arraybuffer";
|
|
||||||
|
|
||||||
xhr.addEventListener("load", async () => {
|
const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey);
|
||||||
if (xhr.status !== 200) {
|
storeFileCache(fileId, fileBuffer); // Intended
|
||||||
reject(new Error("Failed to download file"));
|
return fileBuffer;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileDecrypted = await decryptData(
|
|
||||||
xhr.response as ArrayBuffer,
|
|
||||||
fileEncryptedIv,
|
|
||||||
dataKey,
|
|
||||||
);
|
|
||||||
resolve(fileDecrypted);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Progress, ...
|
|
||||||
|
|
||||||
xhr.open("GET", `/api/file/${fileId}/download`);
|
|
||||||
xhr.send();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
29
src/routes/(fullscreen)/file/downloads/+page.svelte
Normal file
29
src/routes/(fullscreen)/file/downloads/+page.svelte
Normal 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>
|
||||||
67
src/routes/(fullscreen)/file/downloads/File.svelte
Normal file
67
src/routes/(fullscreen)/file/downloads/File.svelte
Normal 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}
|
||||||
29
src/routes/(fullscreen)/file/uploads/+page.svelte
Normal file
29
src/routes/(fullscreen)/file/uploads/+page.svelte
Normal 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>
|
||||||
57
src/routes/(fullscreen)/file/uploads/File.svelte
Normal file
57
src/routes/(fullscreen)/file/uploads/File.svelte
Normal 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { saveAs } from "file-saver";
|
import FileSaver from "file-saver";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { Button, TextButton } from "$lib/components/buttons";
|
import { Button, TextButton } from "$lib/components/buttons";
|
||||||
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
serializeClientKeys,
|
serializeClientKeys,
|
||||||
requestClientRegistration,
|
requestClientRegistration,
|
||||||
storeClientKeys,
|
storeClientKeys,
|
||||||
requestTokenUpgrade,
|
requestSessionUpgrade,
|
||||||
requestInitialMasterKeyRegistration,
|
requestInitialMasterKeyAndHmacSecretRegistration,
|
||||||
} from "./service";
|
} from "./service";
|
||||||
|
|
||||||
import IconKey from "~icons/material-symbols/key";
|
import IconKey from "~icons/material-symbols/key";
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
const clientKeysBlob = new Blob([JSON.stringify(clientKeysSerialized)], {
|
const clientKeysBlob = new Blob([JSON.stringify(clientKeysSerialized)], {
|
||||||
type: "application/json",
|
type: "application/json",
|
||||||
});
|
});
|
||||||
saveAs(clientKeysBlob, "arkvault-clientkey.json");
|
FileSaver.saveAs(clientKeysBlob, "arkvault-clientkey.json");
|
||||||
|
|
||||||
if (!isBeforeContinueBottomSheetOpen) {
|
if (!isBeforeContinueBottomSheetOpen) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -59,19 +59,23 @@
|
|||||||
await storeClientKeys($clientKeyStore);
|
await storeClientKeys($clientKeyStore);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!(await requestTokenUpgrade(
|
!(await requestSessionUpgrade(
|
||||||
data.encryptKeyBase64,
|
data.encryptKeyBase64,
|
||||||
$clientKeyStore.decryptKey,
|
$clientKeyStore.decryptKey,
|
||||||
data.verifyKeyBase64,
|
data.verifyKeyBase64,
|
||||||
$clientKeyStore.signKey,
|
$clientKeyStore.signKey,
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
throw new Error("Failed to upgrade token");
|
throw new Error("Failed to upgrade session");
|
||||||
|
|
||||||
if (
|
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));
|
await goto("/client/pending?redirect=" + encodeURIComponent(data.redirectPath));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { callPostApi } from "$lib/hooks";
|
import { callPostApi } from "$lib/hooks";
|
||||||
import { storeClientKey } from "$lib/indexedDB";
|
import { storeClientKey } from "$lib/indexedDB";
|
||||||
import { signMasterKeyWrapped } from "$lib/modules/crypto";
|
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";
|
import type { ClientKeys } from "$lib/stores";
|
||||||
|
|
||||||
export { requestTokenUpgrade } from "$lib/services/auth";
|
export { requestSessionUpgrade } from "$lib/services/auth";
|
||||||
export { requestClientRegistration } from "$lib/services/key";
|
export { requestClientRegistration } from "$lib/services/key";
|
||||||
|
|
||||||
type SerializedKeyPairs = {
|
type SerializedKeyPairs = {
|
||||||
@@ -44,13 +47,22 @@ export const storeClientKeys = async (clientKeys: ClientKeys) => {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requestInitialMasterKeyRegistration = async (
|
export const requestInitialMasterKeyAndHmacSecretRegistration = async (
|
||||||
masterKeyWrapped: string,
|
masterKeyWrapped: string,
|
||||||
|
hmacSecretWrapped: string,
|
||||||
signKey: CryptoKey,
|
signKey: CryptoKey,
|
||||||
) => {
|
) => {
|
||||||
const res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", {
|
let res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", {
|
||||||
mek: masterKeyWrapped,
|
mek: masterKeyWrapped,
|
||||||
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
import { gotoStateful } from "$lib/hooks";
|
import { gotoStateful } from "$lib/hooks";
|
||||||
import { clientKeyStore } from "$lib/stores";
|
import { clientKeyStore } from "$lib/stores";
|
||||||
import Order from "./Order.svelte";
|
import Order from "./Order.svelte";
|
||||||
import { generateClientKeys, generateInitialMasterKey } from "./service";
|
import {
|
||||||
|
generateClientKeys,
|
||||||
|
generateInitialMasterKey,
|
||||||
|
generateInitialHmacSecret,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
import IconKey from "~icons/material-symbols/key";
|
import IconKey from "~icons/material-symbols/key";
|
||||||
|
|
||||||
@@ -36,12 +40,14 @@
|
|||||||
// TODO: Loading indicator
|
// TODO: Loading indicator
|
||||||
|
|
||||||
const { encryptKey, ...clientKeys } = await generateClientKeys();
|
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", {
|
await gotoStateful("/key/export", {
|
||||||
...clientKeys,
|
...clientKeys,
|
||||||
redirectPath: data.redirectPath,
|
redirectPath: data.redirectPath,
|
||||||
masterKeyWrapped,
|
masterKeyWrapped,
|
||||||
|
hmacSecretWrapped,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import {
|
|||||||
generateSigningKeyPair,
|
generateSigningKeyPair,
|
||||||
exportRSAKeyToBase64,
|
exportRSAKeyToBase64,
|
||||||
makeRSAKeyNonextractable,
|
makeRSAKeyNonextractable,
|
||||||
generateMasterKey,
|
|
||||||
wrapMasterKey,
|
wrapMasterKey,
|
||||||
|
generateMasterKey,
|
||||||
|
makeAESKeyNonextractable,
|
||||||
|
wrapHmacSecret,
|
||||||
|
generateHmacSecret,
|
||||||
} from "$lib/modules/crypto";
|
} from "$lib/modules/crypto";
|
||||||
import { clientKeyStore } from "$lib/stores";
|
import { clientKeyStore } from "$lib/stores";
|
||||||
|
|
||||||
@@ -31,6 +34,14 @@ export const generateClientKeys = async () => {
|
|||||||
export const generateInitialMasterKey = async (encryptKey: CryptoKey) => {
|
export const generateInitialMasterKey = async (encryptKey: CryptoKey) => {
|
||||||
const { masterKey } = await generateMasterKey();
|
const { masterKey } = await generateMasterKey();
|
||||||
return {
|
return {
|
||||||
|
masterKey: await makeAESKeyNonextractable(masterKey),
|
||||||
masterKeyWrapped: await wrapMasterKey(masterKey, encryptKey),
|
masterKeyWrapped: await wrapMasterKey(masterKey, encryptKey),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generateInitialHmacSecret = async (masterKey: CryptoKey) => {
|
||||||
|
const { hmacSecret } = await generateHmacSecret();
|
||||||
|
return {
|
||||||
|
hmacSecretWrapped: await wrapHmacSecret(hmacSecret, masterKey),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
74
src/routes/(fullscreen)/settings/cache/+page.svelte
vendored
Normal file
74
src/routes/(fullscreen)/settings/cache/+page.svelte
vendored
Normal 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>
|
||||||
46
src/routes/(fullscreen)/settings/cache/File.svelte
vendored
Normal file
46
src/routes/(fullscreen)/settings/cache/File.svelte
vendored
Normal 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>
|
||||||
5
src/routes/(fullscreen)/settings/cache/service.ts
vendored
Normal file
5
src/routes/(fullscreen)/settings/cache/service.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
|
||||||
|
|
||||||
|
export const deleteFileCache = async (fileId: number) => {
|
||||||
|
await doDeleteFileCache(fileId);
|
||||||
|
};
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { TopBar } from "$lib/components";
|
import { TopBar } from "$lib/components";
|
||||||
import { FloatingButton } from "$lib/components/buttons";
|
import { FloatingButton } from "$lib/components/buttons";
|
||||||
import { getDirectoryInfo } from "$lib/modules/file";
|
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
|
||||||
import { masterKeyStore, type DirectoryInfo } from "$lib/stores";
|
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
|
||||||
import CreateBottomSheet from "./CreateBottomSheet.svelte";
|
import CreateBottomSheet from "./CreateBottomSheet.svelte";
|
||||||
import CreateDirectoryModal from "./CreateDirectoryModal.svelte";
|
import CreateDirectoryModal from "./CreateDirectoryModal.svelte";
|
||||||
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";
|
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";
|
||||||
import DirectoryEntries from "./DirectoryEntries";
|
import DirectoryEntries from "./DirectoryEntries";
|
||||||
import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte";
|
import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte";
|
||||||
|
import DownloadStatusCard from "./DownloadStatusCard.svelte";
|
||||||
|
import DuplicateFileModal from "./DuplicateFileModal.svelte";
|
||||||
import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte";
|
import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte";
|
||||||
|
import UploadStatusCard from "./UploadStatusCard.svelte";
|
||||||
import {
|
import {
|
||||||
|
requestHmacSecretDownload,
|
||||||
requestDirectoryCreation,
|
requestDirectoryCreation,
|
||||||
requestFileUpload,
|
requestFileUpload,
|
||||||
requestDirectoryEntryRename,
|
requestDirectoryEntryRename,
|
||||||
@@ -25,10 +30,13 @@
|
|||||||
|
|
||||||
let info: Writable<DirectoryInfo | null> | undefined = $state();
|
let info: Writable<DirectoryInfo | null> | undefined = $state();
|
||||||
let fileInput: HTMLInputElement | 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 selectedEntry: SelectedDirectoryEntry | undefined = $state();
|
||||||
|
|
||||||
let isCreateBottomSheetOpen = $state(false);
|
let isCreateBottomSheetOpen = $state(false);
|
||||||
let isCreateDirectoryModalOpen = $state(false);
|
let isCreateDirectoryModalOpen = $state(false);
|
||||||
|
let isDuplicateFileModalOpen = $state(false);
|
||||||
|
|
||||||
let isDirectoryEntryMenuBottomSheetOpen = $state(false);
|
let isDirectoryEntryMenuBottomSheetOpen = $state(false);
|
||||||
let isRenameDirectoryEntryModalOpen = $state(false);
|
let isRenameDirectoryEntryModalOpen = $state(false);
|
||||||
@@ -41,14 +49,40 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const uploadFile = () => {
|
const uploadFile = () => {
|
||||||
const file = fileInput?.files?.[0];
|
const files = fileInput?.files;
|
||||||
if (!file) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
requestFileUpload(file, data.id, $masterKeyStore?.get(1)!).then(() => {
|
for (const file of files) {
|
||||||
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
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(() => {
|
$effect(() => {
|
||||||
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
||||||
});
|
});
|
||||||
@@ -58,7 +92,7 @@
|
|||||||
<title>파일</title>
|
<title>파일</title>
|
||||||
</svelte:head>
|
</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">
|
<div class="flex min-h-full flex-col px-4">
|
||||||
{#if data.id !== "root"}
|
{#if data.id !== "root"}
|
||||||
@@ -67,6 +101,10 @@
|
|||||||
{#if $info}
|
{#if $info}
|
||||||
{@const topMargin = data.id === "root" ? "mt-4" : ""}
|
{@const topMargin = data.id === "root" ? "mt-4" : ""}
|
||||||
<div class="mb-4 flex flex-grow flex-col {topMargin}">
|
<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}
|
{#key $info}
|
||||||
<DirectoryEntries
|
<DirectoryEntries
|
||||||
info={$info}
|
info={$info}
|
||||||
@@ -99,6 +137,22 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CreateDirectoryModal bind:isOpen={isCreateDirectoryModalOpen} onCreateClick={createDirectory} />
|
<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
|
<DirectoryEntryMenuBottomSheet
|
||||||
bind:isOpen={isDirectoryEntryMenuBottomSheetOpen}
|
bind:isOpen={isDirectoryEntryMenuBottomSheetOpen}
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import type { Writable } from "svelte/store";
|
import { get, type Writable } from "svelte/store";
|
||||||
import { getDirectoryInfo, getFileInfo } from "$lib/modules/file";
|
import {
|
||||||
import { masterKeyStore, type DirectoryInfo, type FileInfo } from "$lib/stores";
|
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 File from "./File.svelte";
|
||||||
import SubDirectory from "./SubDirectory.svelte";
|
import SubDirectory from "./SubDirectory.svelte";
|
||||||
import { SortBy, sortEntries } from "./service";
|
import { SortBy, sortEntries } from "./service";
|
||||||
|
import UploadingFile from "./UploadingFile.svelte";
|
||||||
import type { SelectedDirectoryEntry } from "../service";
|
import type { SelectedDirectoryEntry } from "../service";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,37 +28,95 @@
|
|||||||
|
|
||||||
let { info, onEntryClick, onEntryMenuClick, sortBy = SortBy.NAME_ASC }: Props = $props();
|
let { info, onEntryClick, onEntryMenuClick, sortBy = SortBy.NAME_ASC }: Props = $props();
|
||||||
|
|
||||||
let subDirectoryInfos: Writable<DirectoryInfo | null>[] = $state([]);
|
interface DirectoryEntry {
|
||||||
let fileInfos: Writable<FileInfo | null>[] = $state([]);
|
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(() => {
|
$effect(() => {
|
||||||
// TODO: Fix duplicated requests
|
// TODO: Fix duplicated requests
|
||||||
|
|
||||||
subDirectoryInfos = info.subDirectoryIds.map((id) =>
|
subDirectories = info.subDirectoryIds.map((id) => {
|
||||||
getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!),
|
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 = () => {
|
const sort = () => {
|
||||||
sortEntries(subDirectoryInfos, sortBy);
|
sortEntries(subDirectories, sortBy);
|
||||||
sortEntries(fileInfos, sortBy);
|
sortEntries(files, sortBy);
|
||||||
};
|
};
|
||||||
return untrack(() => {
|
return untrack(() => {
|
||||||
const unsubscribes = subDirectoryInfos
|
sort();
|
||||||
.map((subDirectoryInfo) => subDirectoryInfo.subscribe(sort))
|
|
||||||
.concat(fileInfos.map((fileInfo) => fileInfo.subscribe(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());
|
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if info.subDirectoryIds.length + info.fileIds.length > 0}
|
{#if subDirectories.length + files.length > 0}
|
||||||
<div class="pb-[4.5rem]">
|
<div class="space-y-1 pb-[4.5rem]">
|
||||||
{#each subDirectoryInfos as subDirectory}
|
{#each subDirectories as { info }}
|
||||||
<SubDirectory info={subDirectory} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
|
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
|
||||||
{/each}
|
{/each}
|
||||||
{#each fileInfos as file}
|
{#each files as file}
|
||||||
<File info={file} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
|
{#if file.type === "file"}
|
||||||
|
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
|
||||||
|
{:else}
|
||||||
|
<UploadingFile status={file.info} />
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Writable } from "svelte/store";
|
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 type { SelectedDirectoryEntry } from "../service";
|
||||||
|
|
||||||
import IconDraft from "~icons/material-symbols/draft";
|
import IconDraft from "~icons/material-symbols/draft";
|
||||||
@@ -16,6 +17,8 @@
|
|||||||
|
|
||||||
const openFile = () => {
|
const openFile = () => {
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info!;
|
const { id, dataKey, dataKeyVersion, name } = $info!;
|
||||||
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
|
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -25,6 +28,8 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info!;
|
const { id, dataKey, dataKeyVersion, name } = $info!;
|
||||||
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
|
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -34,14 +39,19 @@
|
|||||||
{#if $info}
|
{#if $info}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- 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 id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
|
||||||
<div class="flex-shrink-0 text-lg">
|
<div class="flex-shrink-0 text-lg">
|
||||||
<IconDraft class="text-blue-400" />
|
<IconDraft class="text-blue-400" />
|
||||||
</div>
|
</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}
|
{$info.name}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-xs text-gray-800">
|
||||||
|
{formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
id="open-menu"
|
id="open-menu"
|
||||||
onclick={openMenu}
|
onclick={openMenu}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Writable } from "svelte/store";
|
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 type { SelectedDirectoryEntry } from "../service";
|
||||||
|
|
||||||
import IconFolder from "~icons/material-symbols/folder";
|
import IconFolder from "~icons/material-symbols/folder";
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
const openDirectory = () => {
|
const openDirectory = () => {
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
||||||
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -27,6 +29,8 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
||||||
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -36,7 +40,7 @@
|
|||||||
{#if $info}
|
{#if $info}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- 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 id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
|
||||||
<div class="flex-shrink-0 text-lg">
|
<div class="flex-shrink-0 text-lg">
|
||||||
<IconFolder />
|
<IconFolder />
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
import { get, type Writable } from "svelte/store";
|
|
||||||
import type { DirectoryInfo, FileInfo } from "$lib/stores";
|
|
||||||
|
|
||||||
export enum SortBy {
|
export enum SortBy {
|
||||||
NAME_ASC,
|
NAME_ASC,
|
||||||
NAME_DESC,
|
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) => {
|
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;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
|
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
|
||||||
|
|
||||||
export const sortEntries = <T extends DirectoryInfo | FileInfo>(
|
export const sortEntries = <T extends { name?: string }>(
|
||||||
entries: Writable<T | null>[],
|
entries: T[],
|
||||||
sortBy: SortBy = SortBy.NAME_ASC,
|
sortBy: SortBy = SortBy.NAME_ASC,
|
||||||
) => {
|
) => {
|
||||||
let sortFunc: SortFunc;
|
let sortFunc: SortFunc;
|
||||||
@@ -26,5 +25,5 @@ export const sortEntries = <T extends DirectoryInfo | FileInfo>(
|
|||||||
sortFunc = sortByNameDesc;
|
sortFunc = sortByNameDesc;
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.sort((a, b) => sortFunc(get(a), get(b)));
|
entries.sort((a, b) => sortFunc(a.name, b.name));
|
||||||
};
|
};
|
||||||
|
|||||||
41
src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte
Normal file
41
src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte
Normal 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}
|
||||||
30
src/routes/(main)/directory/[[id]]/DuplicateFileModal.svelte
Normal file
30
src/routes/(main)/directory/[[id]]/DuplicateFileModal.svelte
Normal 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
Reference in New Issue
Block a user