diff --git a/Dockerfile b/Dockerfile
index 87e0df0..eec42f6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,6 +13,8 @@ COPY . .
RUN pnpm install --offline
RUN pnpm build
+RUN sed -i "s/http\.createServer()/http.createServer({ requestTimeout: 0 })/g" ./build/index.js
+
# Deploy Stage
FROM base
RUN pnpm fetch --prod
diff --git a/drizzle/0000_unknown_stark_industries.sql b/drizzle/0000_regular_the_watchers.sql
similarity index 97%
rename from drizzle/0000_unknown_stark_industries.sql
rename to drizzle/0000_regular_the_watchers.sql
index 28a9787..6cbdec8 100644
--- a/drizzle/0000_unknown_stark_industries.sql
+++ b/drizzle/0000_regular_the_watchers.sql
@@ -59,7 +59,10 @@ CREATE TABLE `file` (
`content_hmac` text,
`content_type` text NOT NULL,
`encrypted_content_iv` text NOT NULL,
+ `encrypted_content_hash` text NOT NULL,
`encrypted_name` text NOT NULL,
+ `encrypted_created_at` text,
+ `encrypted_last_modified_at` text NOT NULL,
FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action,
@@ -94,7 +97,7 @@ CREATE TABLE `hmac_secret_key_log` (
`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 (`action_by`) REFERENCES `client`(`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
diff --git a/drizzle/0001_blushing_alice.sql b/drizzle/0001_blushing_alice.sql
deleted file mode 100644
index f68ba02..0000000
--- a/drizzle/0001_blushing_alice.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-ALTER TABLE `file` ADD `encrypted_created_at` text;--> statement-breakpoint
-ALTER TABLE `file` ADD `encrypted_last_modified_at` text NOT NULL;
\ No newline at end of file
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
index 42641f1..27a42bc 100644
--- a/drizzle/meta/0000_snapshot.json
+++ b/drizzle/meta/0000_snapshot.json
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
- "id": "928e5669-81cf-486c-9122-8ee64fc9f457",
+ "id": "396a26d6-6f55-4162-a23e-c1117f3a3757",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"client": {
@@ -470,12 +470,33 @@
"notNull": true,
"autoincrement": false
},
+ "encrypted_content_hash": {
+ "name": "encrypted_content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
"encrypted_name": {
"name": "encrypted_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
+ },
+ "encrypted_created_at": {
+ "name": "encrypted_created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "encrypted_last_modified_at": {
+ "name": "encrypted_last_modified_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
}
},
"indexes": {
@@ -763,10 +784,10 @@
"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",
+ "hmac_secret_key_log_action_by_client_id_fk": {
+ "name": "hmac_secret_key_log_action_by_client_id_fk",
"tableFrom": "hmac_secret_key_log",
- "tableTo": "user",
+ "tableTo": "client",
"columnsFrom": [
"action_by"
],
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
deleted file mode 100644
index 3425d7b..0000000
--- a/drizzle/meta/0001_snapshot.json
+++ /dev/null
@@ -1,1301 +0,0 @@
-{
- "version": "6",
- "dialect": "sqlite",
- "id": "5e999e6f-1ec4-40b0-bb10-741ffc6da4af",
- "prevId": "928e5669-81cf-486c-9122-8ee64fc9f457",
- "tables": {
- "client": {
- "name": "client",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "encryption_public_key": {
- "name": "encryption_public_key",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "signature_public_key": {
- "name": "signature_public_key",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "client_encryption_public_key_unique": {
- "name": "client_encryption_public_key_unique",
- "columns": [
- "encryption_public_key"
- ],
- "isUnique": true
- },
- "client_signature_public_key_unique": {
- "name": "client_signature_public_key_unique",
- "columns": [
- "signature_public_key"
- ],
- "isUnique": true
- },
- "client_encryption_public_key_signature_public_key_unique": {
- "name": "client_encryption_public_key_signature_public_key_unique",
- "columns": [
- "encryption_public_key",
- "signature_public_key"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {}
- },
- "user_client": {
- "name": "user_client",
- "columns": {
- "user_id": {
- "name": "user_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "client_id": {
- "name": "client_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "state": {
- "name": "state",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'challenging'"
- }
- },
- "indexes": {},
- "foreignKeys": {
- "user_client_user_id_user_id_fk": {
- "name": "user_client_user_id_user_id_fk",
- "tableFrom": "user_client",
- "tableTo": "user",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "user_client_client_id_client_id_fk": {
- "name": "user_client_client_id_client_id_fk",
- "tableFrom": "user_client",
- "tableTo": "client",
- "columnsFrom": [
- "client_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "user_client_user_id_client_id_pk": {
- "columns": [
- "client_id",
- "user_id"
- ],
- "name": "user_client_user_id_client_id_pk"
- }
- },
- "uniqueConstraints": {}
- },
- "user_client_challenge": {
- "name": "user_client_challenge",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": false
- },
- "user_id": {
- "name": "user_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "client_id": {
- "name": "client_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "answer": {
- "name": "answer",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "allowed_ip": {
- "name": "allowed_ip",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "expires_at": {
- "name": "expires_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "user_client_challenge_answer_unique": {
- "name": "user_client_challenge_answer_unique",
- "columns": [
- "answer"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {
- "user_client_challenge_user_id_user_id_fk": {
- "name": "user_client_challenge_user_id_user_id_fk",
- "tableFrom": "user_client_challenge",
- "tableTo": "user",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "user_client_challenge_client_id_client_id_fk": {
- "name": "user_client_challenge_client_id_client_id_fk",
- "tableFrom": "user_client_challenge",
- "tableTo": "client",
- "columnsFrom": [
- "client_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "user_client_challenge_user_id_client_id_user_client_user_id_client_id_fk": {
- "name": "user_client_challenge_user_id_client_id_user_client_user_id_client_id_fk",
- "tableFrom": "user_client_challenge",
- "tableTo": "user_client",
- "columnsFrom": [
- "user_id",
- "client_id"
- ],
- "columnsTo": [
- "user_id",
- "client_id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {}
- },
- "directory": {
- "name": "directory",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "parent_id": {
- "name": "parent_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "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
- },
- "encrypted_data_encryption_key": {
- "name": "encrypted_data_encryption_key",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "data_encryption_key_version": {
- "name": "data_encryption_key_version",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "encrypted_name": {
- "name": "encrypted_name",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "directory_encrypted_data_encryption_key_unique": {
- "name": "directory_encrypted_data_encryption_key_unique",
- "columns": [
- "encrypted_data_encryption_key"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {
- "directory_user_id_user_id_fk": {
- "name": "directory_user_id_user_id_fk",
- "tableFrom": "directory",
- "tableTo": "user",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "directory_parent_id_directory_id_fk": {
- "name": "directory_parent_id_directory_id_fk",
- "tableFrom": "directory",
- "tableTo": "directory",
- "columnsFrom": [
- "parent_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "directory_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": {
- "name": "directory_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk",
- "tableFrom": "directory",
- "tableTo": "master_encryption_key",
- "columnsFrom": [
- "user_id",
- "master_encryption_key_version"
- ],
- "columnsTo": [
- "user_id",
- "version"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {}
- },
- "directory_log": {
- "name": "directory_log",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "directory_id": {
- "name": "directory_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "timestamp": {
- "name": "timestamp",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "action": {
- "name": "action",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "new_name": {
- "name": "new_name",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "directory_log_directory_id_directory_id_fk": {
- "name": "directory_log_directory_id_directory_id_fk",
- "tableFrom": "directory_log",
- "tableTo": "directory",
- "columnsFrom": [
- "directory_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {}
- },
- "file": {
- "name": "file",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "parent_id": {
- "name": "parent_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "user_id": {
- "name": "user_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "path": {
- "name": "path",
- "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_data_encryption_key": {
- "name": "encrypted_data_encryption_key",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "data_encryption_key_version": {
- "name": "data_encryption_key_version",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "hmac_secret_key_version": {
- "name": "hmac_secret_key_version",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "content_hmac": {
- "name": "content_hmac",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "content_type": {
- "name": "content_type",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "encrypted_content_iv": {
- "name": "encrypted_content_iv",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "encrypted_name": {
- "name": "encrypted_name",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "encrypted_created_at": {
- "name": "encrypted_created_at",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "encrypted_last_modified_at": {
- "name": "encrypted_last_modified_at",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "file_path_unique": {
- "name": "file_path_unique",
- "columns": [
- "path"
- ],
- "isUnique": true
- },
- "file_encrypted_data_encryption_key_unique": {
- "name": "file_encrypted_data_encryption_key_unique",
- "columns": [
- "encrypted_data_encryption_key"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {
- "file_parent_id_directory_id_fk": {
- "name": "file_parent_id_directory_id_fk",
- "tableFrom": "file",
- "tableTo": "directory",
- "columnsFrom": [
- "parent_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "file_user_id_user_id_fk": {
- "name": "file_user_id_user_id_fk",
- "tableFrom": "file",
- "tableTo": "user",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "file_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": {
- "name": "file_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk",
- "tableFrom": "file",
- "tableTo": "master_encryption_key",
- "columnsFrom": [
- "user_id",
- "master_encryption_key_version"
- ],
- "columnsTo": [
- "user_id",
- "version"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "file_user_id_hmac_secret_key_version_hmac_secret_key_user_id_version_fk": {
- "name": "file_user_id_hmac_secret_key_version_hmac_secret_key_user_id_version_fk",
- "tableFrom": "file",
- "tableTo": "hmac_secret_key",
- "columnsFrom": [
- "user_id",
- "hmac_secret_key_version"
- ],
- "columnsTo": [
- "user_id",
- "version"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {}
- },
- "file_log": {
- "name": "file_log",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "file_id": {
- "name": "file_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "timestamp": {
- "name": "timestamp",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "action": {
- "name": "action",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "new_name": {
- "name": "new_name",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "file_log_file_id_file_id_fk": {
- "name": "file_log_file_id_file_id_fk",
- "tableFrom": "file_log",
- "tableTo": "file",
- "columnsFrom": [
- "file_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {}
- },
- "hmac_secret_key": {
- "name": "hmac_secret_key",
- "columns": {
- "user_id": {
- "name": "user_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "version": {
- "name": "version",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "state": {
- "name": "state",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "master_encryption_key_version": {
- "name": "master_encryption_key_version",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "encrypted_key": {
- "name": "encrypted_key",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "hmac_secret_key_encrypted_key_unique": {
- "name": "hmac_secret_key_encrypted_key_unique",
- "columns": [
- "encrypted_key"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {
- "hmac_secret_key_user_id_user_id_fk": {
- "name": "hmac_secret_key_user_id_user_id_fk",
- "tableFrom": "hmac_secret_key",
- "tableTo": "user",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "hmac_secret_key_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": {
- "name": "hmac_secret_key_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk",
- "tableFrom": "hmac_secret_key",
- "tableTo": "master_encryption_key",
- "columnsFrom": [
- "user_id",
- "master_encryption_key_version"
- ],
- "columnsTo": [
- "user_id",
- "version"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "hmac_secret_key_user_id_version_pk": {
- "columns": [
- "user_id",
- "version"
- ],
- "name": "hmac_secret_key_user_id_version_pk"
- }
- },
- "uniqueConstraints": {}
- },
- "hmac_secret_key_log": {
- "name": "hmac_secret_key_log",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "user_id": {
- "name": "user_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "hmac_secret_key_version": {
- "name": "hmac_secret_key_version",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "timestamp": {
- "name": "timestamp",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "action": {
- "name": "action",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "action_by": {
- "name": "action_by",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "hmac_secret_key_log_user_id_user_id_fk": {
- "name": "hmac_secret_key_log_user_id_user_id_fk",
- "tableFrom": "hmac_secret_key_log",
- "tableTo": "user",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "hmac_secret_key_log_action_by_user_id_fk": {
- "name": "hmac_secret_key_log_action_by_user_id_fk",
- "tableFrom": "hmac_secret_key_log",
- "tableTo": "user",
- "columnsFrom": [
- "action_by"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "hmac_secret_key_log_user_id_hmac_secret_key_version_hmac_secret_key_user_id_version_fk": {
- "name": "hmac_secret_key_log_user_id_hmac_secret_key_version_hmac_secret_key_user_id_version_fk",
- "tableFrom": "hmac_secret_key_log",
- "tableTo": "hmac_secret_key",
- "columnsFrom": [
- "user_id",
- "hmac_secret_key_version"
- ],
- "columnsTo": [
- "user_id",
- "version"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {}
- },
- "client_master_encryption_key": {
- "name": "client_master_encryption_key",
- "columns": {
- "user_id": {
- "name": "user_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "client_id": {
- "name": "client_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "version": {
- "name": "version",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "encrypted_key": {
- "name": "encrypted_key",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "encrypted_key_signature": {
- "name": "encrypted_key_signature",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "client_master_encryption_key_user_id_user_id_fk": {
- "name": "client_master_encryption_key_user_id_user_id_fk",
- "tableFrom": "client_master_encryption_key",
- "tableTo": "user",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "client_master_encryption_key_client_id_client_id_fk": {
- "name": "client_master_encryption_key_client_id_client_id_fk",
- "tableFrom": "client_master_encryption_key",
- "tableTo": "client",
- "columnsFrom": [
- "client_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "client_master_encryption_key_user_id_version_master_encryption_key_user_id_version_fk": {
- "name": "client_master_encryption_key_user_id_version_master_encryption_key_user_id_version_fk",
- "tableFrom": "client_master_encryption_key",
- "tableTo": "master_encryption_key",
- "columnsFrom": [
- "user_id",
- "version"
- ],
- "columnsTo": [
- "user_id",
- "version"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "client_master_encryption_key_user_id_client_id_version_pk": {
- "columns": [
- "client_id",
- "user_id",
- "version"
- ],
- "name": "client_master_encryption_key_user_id_client_id_version_pk"
- }
- },
- "uniqueConstraints": {}
- },
- "master_encryption_key": {
- "name": "master_encryption_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
- },
- "retired_at": {
- "name": "retired_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "master_encryption_key_user_id_user_id_fk": {
- "name": "master_encryption_key_user_id_user_id_fk",
- "tableFrom": "master_encryption_key",
- "tableTo": "user",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {
- "master_encryption_key_user_id_version_pk": {
- "columns": [
- "user_id",
- "version"
- ],
- "name": "master_encryption_key_user_id_version_pk"
- }
- },
- "uniqueConstraints": {}
- },
- "master_encryption_key_log": {
- "name": "master_encryption_key_log",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "user_id": {
- "name": "user_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "master_encryption_key_version": {
- "name": "master_encryption_key_version",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "timestamp": {
- "name": "timestamp",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "action": {
- "name": "action",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "action_by": {
- "name": "action_by",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "master_encryption_key_log_user_id_user_id_fk": {
- "name": "master_encryption_key_log_user_id_user_id_fk",
- "tableFrom": "master_encryption_key_log",
- "tableTo": "user",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "master_encryption_key_log_action_by_client_id_fk": {
- "name": "master_encryption_key_log_action_by_client_id_fk",
- "tableFrom": "master_encryption_key_log",
- "tableTo": "client",
- "columnsFrom": [
- "action_by"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "master_encryption_key_log_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": {
- "name": "master_encryption_key_log_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk",
- "tableFrom": "master_encryption_key_log",
- "tableTo": "master_encryption_key",
- "columnsFrom": [
- "user_id",
- "master_encryption_key_version"
- ],
- "columnsTo": [
- "user_id",
- "version"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {}
- },
- "session": {
- "name": "session",
- "columns": {
- "id": {
- "name": "id",
- "type": "text",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": false
- },
- "user_id": {
- "name": "user_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "client_id": {
- "name": "client_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "last_used_at": {
- "name": "last_used_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "last_used_by_ip": {
- "name": "last_used_by_ip",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "last_used_by_user_agent": {
- "name": "last_used_by_user_agent",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- }
- },
- "indexes": {
- "session_user_id_client_id_unique": {
- "name": "session_user_id_client_id_unique",
- "columns": [
- "user_id",
- "client_id"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {
- "session_user_id_user_id_fk": {
- "name": "session_user_id_user_id_fk",
- "tableFrom": "session",
- "tableTo": "user",
- "columnsFrom": [
- "user_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "session_client_id_client_id_fk": {
- "name": "session_client_id_client_id_fk",
- "tableFrom": "session",
- "tableTo": "client",
- "columnsFrom": [
- "client_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {}
- },
- "session_upgrade_challenge": {
- "name": "session_upgrade_challenge",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": false
- },
- "session_id": {
- "name": "session_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "client_id": {
- "name": "client_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "answer": {
- "name": "answer",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "allowed_ip": {
- "name": "allowed_ip",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "expires_at": {
- "name": "expires_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "session_upgrade_challenge_session_id_unique": {
- "name": "session_upgrade_challenge_session_id_unique",
- "columns": [
- "session_id"
- ],
- "isUnique": true
- },
- "session_upgrade_challenge_answer_unique": {
- "name": "session_upgrade_challenge_answer_unique",
- "columns": [
- "answer"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {
- "session_upgrade_challenge_session_id_session_id_fk": {
- "name": "session_upgrade_challenge_session_id_session_id_fk",
- "tableFrom": "session_upgrade_challenge",
- "tableTo": "session",
- "columnsFrom": [
- "session_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- },
- "session_upgrade_challenge_client_id_client_id_fk": {
- "name": "session_upgrade_challenge_client_id_client_id_fk",
- "tableFrom": "session_upgrade_challenge",
- "tableTo": "client",
- "columnsFrom": [
- "client_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {}
- },
- "user": {
- "name": "user",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "password": {
- "name": "password",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "nickname": {
- "name": "nickname",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "user_email_unique": {
- "name": "user_email_unique",
- "columns": [
- "email"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {}
- }
- },
- "enums": {},
- "_meta": {
- "schemas": {},
- "tables": {},
- "columns": {}
- },
- "internal": {
- "indexes": {}
- }
-}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 65be42a..bdbdf66 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -5,15 +5,8 @@
{
"idx": 0,
"version": "6",
- "when": 1736704436996,
- "tag": "0000_unknown_stark_industries",
- "breakpoints": true
- },
- {
- "idx": 1,
- "version": "6",
- "when": 1736720831242,
- "tag": "0001_blushing_alice",
+ "when": 1737219722656,
+ "tag": "0000_regular_the_watchers",
"breakpoints": true
}
]
diff --git a/package.json b/package.json
index dfa988e..3a4adf2 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.7",
"autoprefixer": "^10.4.20",
+ "axios": "^1.7.9",
"dexie": "^4.0.10",
"drizzle-kit": "^0.22.8",
"eslint": "^9.17.0",
@@ -38,6 +39,7 @@
"globals": "^15.14.0",
"heic2any": "^0.0.4",
"mime": "^4.0.6",
+ "p-limit": "^6.2.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.9",
@@ -50,6 +52,7 @@
"vite": "^5.4.11"
},
"dependencies": {
+ "@fastify/busboy": "^3.1.1",
"argon2": "^0.41.1",
"better-sqlite3": "^11.7.2",
"drizzle-orm": "^0.33.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dccbc50..ae948dc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@fastify/busboy':
+ specifier: ^3.1.1
+ version: 3.1.1
argon2:
specifier: ^0.41.1
version: 0.41.1
@@ -60,6 +63,9 @@ importers:
autoprefixer:
specifier: ^10.4.20
version: 10.4.20(postcss@8.4.49)
+ axios:
+ specifier: ^1.7.9
+ version: 1.7.9
dexie:
specifier: ^4.0.10
version: 4.0.10
@@ -93,6 +99,9 @@ importers:
mime:
specifier: ^4.0.6
version: 4.0.6
+ p-limit:
+ specifier: ^6.2.0
+ version: 6.2.0
prettier:
specifier: ^3.4.2
version: 3.4.2
@@ -602,6 +611,9 @@ packages:
resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@fastify/busboy@3.1.1':
+ resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==}
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -969,6 +981,9 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
autoprefixer@10.4.20:
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
engines: {node: ^10 || ^12 || >=14}
@@ -976,6 +991,9 @@ packages:
peerDependencies:
postcss: ^8.1.0
+ axios@1.7.9:
+ resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==}
+
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -1057,6 +1075,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@@ -1111,6 +1133,10 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
detect-libc@2.0.3:
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
engines: {node: '>=8'}
@@ -1406,10 +1432,23 @@ packages:
flatted@3.3.2:
resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==}
+ follow-redirects@1.15.9:
+ resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
foreground-child@3.3.0:
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
engines: {node: '>=14'}
+ form-data@4.0.1:
+ resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
+ engines: {node: '>= 6'}
+
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
@@ -1613,6 +1652,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
+ mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+
mime@4.0.6:
resolution: {integrity: sha512-4rGt7rvQHBbaSOF9POGkk1ocRP16Md1x36Xma8sz8h8/vfCUI2OtEIeCqe4Ofes853x4xDoPiFLIT47J5fI/7A==}
engines: {node: '>=16'}
@@ -1713,6 +1760,10 @@ packages:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
+ p-limit@6.2.0:
+ resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==}
+ engines: {node: '>=18'}
+
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
@@ -1907,6 +1958,9 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
pump@3.0.2:
resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==}
@@ -2257,6 +2311,10 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ yocto-queue@1.1.1:
+ resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==}
+ engines: {node: '>=12.20'}
+
zimmerframe@1.1.2:
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
@@ -2543,6 +2601,8 @@ snapshots:
dependencies:
levn: 0.4.1
+ '@fastify/busboy@3.1.1': {}
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6':
@@ -2911,6 +2971,8 @@ snapshots:
aria-query@5.3.2: {}
+ asynckit@0.4.0: {}
+
autoprefixer@10.4.20(postcss@8.4.49):
dependencies:
browserslist: 4.24.4
@@ -2921,6 +2983,14 @@ snapshots:
postcss: 8.4.49
postcss-value-parser: 4.2.0
+ axios@1.7.9:
+ dependencies:
+ follow-redirects: 1.15.9
+ form-data: 4.0.1
+ proxy-from-env: 1.1.0
+ transitivePeerDependencies:
+ - debug
+
axobject-query@4.1.0: {}
balanced-match@1.0.2: {}
@@ -3008,6 +3078,10 @@ snapshots:
color-name@1.1.4: {}
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+
commander@4.1.1: {}
commondir@1.0.1: {}
@@ -3044,6 +3118,8 @@ snapshots:
deepmerge@4.3.1: {}
+ delayed-stream@1.0.0: {}
+
detect-libc@2.0.3: {}
devalue@5.1.1: {}
@@ -3340,11 +3416,19 @@ snapshots:
flatted@3.3.2: {}
+ follow-redirects@1.15.9: {}
+
foreground-child@3.3.0:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
+ form-data@4.0.1:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ mime-types: 2.1.35
+
fraction.js@4.3.7: {}
fs-constants@1.0.0: {}
@@ -3511,6 +3595,12 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
+ mime-db@1.52.0: {}
+
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+
mime@4.0.6: {}
mimic-response@3.1.0: {}
@@ -3595,6 +3685,10 @@ snapshots:
dependencies:
yocto-queue: 0.1.0
+ p-limit@6.2.0:
+ dependencies:
+ yocto-queue: 1.1.1
+
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
@@ -3718,6 +3812,8 @@ snapshots:
prettier@3.4.2: {}
+ proxy-from-env@1.1.0: {}
+
pump@3.0.2:
dependencies:
end-of-stream: 1.4.4
@@ -4084,6 +4180,8 @@ snapshots:
yocto-queue@0.1.0: {}
+ yocto-queue@1.1.1: {}
+
zimmerframe@1.1.2: {}
zod@3.24.1: {}
diff --git a/src/app.html b/src/app.html
index 4471298..2e7fd3e 100644
--- a/src/app.html
+++ b/src/app.html
@@ -1,5 +1,5 @@
-
+
diff --git a/src/hooks.client.ts b/src/hooks.client.ts
index 3f0ccfb..ec6f620 100644
--- a/src/hooks.client.ts
+++ b/src/hooks.client.ts
@@ -1,5 +1,7 @@
import type { ClientInit } from "@sveltejs/kit";
-import { getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
+import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
+import { prepareFileCache } from "$lib/modules/file";
+import { prepareOpfs } from "$lib/modules/opfs";
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
const prepareClientKeyStore = async () => {
@@ -29,5 +31,13 @@ const prepareHmacSecretStore = async () => {
};
export const init: ClientInit = async () => {
- await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore(), prepareHmacSecretStore()]);
+ await Promise.all([
+ prepareFileCache(),
+ prepareClientKeyStore(),
+ prepareMasterKeyStore(),
+ prepareHmacSecretStore(),
+ prepareOpfs(),
+ ]);
+
+ cleanupDanglingInfos(); // Intended
};
diff --git a/src/lib/indexedDB/cacheIndex.ts b/src/lib/indexedDB/cacheIndex.ts
new file mode 100644
index 0000000..c820007
--- /dev/null
+++ b/src/lib/indexedDB/cacheIndex.ts
@@ -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;
+};
+
+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);
+};
diff --git a/src/lib/indexedDB/filesystem.ts b/src/lib/indexedDB/filesystem.ts
new file mode 100644
index 0000000..5c9fc4d
--- /dev/null
+++ b/src/lib/indexedDB/filesystem.ts
@@ -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;
+ file: EntityTable;
+};
+
+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(),
+ ]);
+};
diff --git a/src/lib/indexedDB/index.ts b/src/lib/indexedDB/index.ts
new file mode 100644
index 0000000..4ca1202
--- /dev/null
+++ b/src/lib/indexedDB/index.ts
@@ -0,0 +1,3 @@
+export * from "./cacheIndex";
+export * from "./filesystem";
+export * from "./keyStore";
diff --git a/src/lib/indexedDB.ts b/src/lib/indexedDB/keyStore.ts
similarity index 100%
rename from src/lib/indexedDB.ts
rename to src/lib/indexedDB/keyStore.ts
diff --git a/src/lib/modules/file.ts b/src/lib/modules/file.ts
deleted file mode 100644
index 82d136e..0000000
--- a/src/lib/modules/file.ts
+++ /dev/null
@@ -1,98 +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,
-) => {
- 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 decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
- return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
-};
-
-const fetchFileInfo = async (
- fileId: number,
- masterKey: CryptoKey,
- infoStore: Writable,
-) => {
- 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),
- createdAt:
- metadata.createdAt && metadata.createdAtIv
- ? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
- : undefined,
- lastModifiedAt: await decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, 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;
-};
diff --git a/src/lib/modules/file/cache.ts b/src/lib/modules/file/cache.ts
new file mode 100644
index 0000000..fe3c66c
--- /dev/null
+++ b/src/lib/modules/file/cache.ts
@@ -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();
+
+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);
+};
diff --git a/src/lib/modules/file/download.ts b/src/lib/modules/file/download.ts
new file mode 100644
index 0000000..b0efb30
--- /dev/null
+++ b/src/lib/modules/file/download.ts
@@ -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, 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,
+ 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({
+ 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;
+ }
+};
diff --git a/src/lib/modules/file/index.ts b/src/lib/modules/file/index.ts
new file mode 100644
index 0000000..42a5613
--- /dev/null
+++ b/src/lib/modules/file/index.ts
@@ -0,0 +1,3 @@
+export * from "./cache";
+export * from "./download";
+export * from "./upload";
diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.ts
new file mode 100644
index 0000000..a2a9707
--- /dev/null
+++ b/src/lib/modules/file/upload.ts
@@ -0,0 +1,231 @@
+import axios from "axios";
+import ExifReader from "exifreader";
+import { limitFunction } from "p-limit";
+import { writable, type Writable } from "svelte/store";
+import {
+ encodeToBase64,
+ generateDataKey,
+ wrapDataKey,
+ encryptData,
+ encryptString,
+ digestMessage,
+ signMessageHmac,
+} from "$lib/modules/crypto";
+import 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) => {
+ 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,
+ file: File,
+ fileBuffer: ArrayBuffer,
+ masterKey: MasterKey,
+ ) => {
+ status.update((value) => {
+ value.status = "encrypting";
+ return value;
+ });
+
+ const fileType = getFileType(file);
+
+ let createdAt;
+ if (fileType.startsWith("image/")) {
+ createdAt = extractExifDateTime(fileBuffer);
+ }
+
+ const { dataKey, dataKeyVersion } = await generateDataKey();
+ const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
+
+ const fileEncrypted = await encryptData(fileBuffer, dataKey);
+ const fileEncryptedHash = encodeToBase64(await digestMessage(fileEncrypted.ciphertext));
+
+ const nameEncrypted = await encryptString(file.name, dataKey);
+ const createdAtEncrypted =
+ createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
+ const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
+
+ status.update((value) => {
+ value.status = "upload-pending";
+ return value;
+ });
+
+ return {
+ dataKeyWrapped,
+ dataKeyVersion,
+ fileType,
+ fileEncrypted,
+ fileEncryptedHash,
+ nameEncrypted,
+ createdAtEncrypted,
+ lastModifiedAtEncrypted,
+ };
+ },
+ { concurrency: 4 },
+);
+
+const requestFileUpload = limitFunction(
+ async (status: Writable, 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,
+) => {
+ const status = writable({
+ 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,
+ fileType,
+ fileEncrypted,
+ fileEncryptedHash,
+ 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]));
+ form.set("checksum", fileEncryptedHash);
+
+ await requestFileUpload(status, form);
+ return true;
+ } catch (e) {
+ status.update((value) => {
+ value.status = "error";
+ return value;
+ });
+ throw e;
+ }
+};
diff --git a/src/lib/modules/filesystem.ts b/src/lib/modules/filesystem.ts
new file mode 100644
index 0000000..1a1ff0f
--- /dev/null
+++ b/src/lib/modules/filesystem.ts
@@ -0,0 +1,208 @@
+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>();
+const fileInfoStore = new Map>();
+
+const fetchDirectoryInfoFromIndexedDB = async (
+ id: DirectoryId,
+ info: Writable,
+) => {
+ 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,
+ masterKey: CryptoKey,
+) => {
+ const res = await callGetApi(`/api/directory/${id}`);
+ if (res.status === 404) {
+ info.set(null);
+ await deleteDirectoryInfo(id as number);
+ return;
+ } 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,
+ 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) => {
+ 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,
+ masterKey: CryptoKey,
+) => {
+ const res = await callGetApi(`/api/file/${id}`);
+ if (res.status === 404) {
+ info.set(null);
+ await deleteFileInfo(id);
+ return;
+ } 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, 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;
+};
diff --git a/src/lib/modules/opfs.ts b/src/lib/modules/opfs.ts
new file mode 100644
index 0000000..5ac70da
--- /dev/null
+++ b/src/lib/modules/opfs.ts
@@ -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);
+};
diff --git a/src/lib/modules/util.ts b/src/lib/modules/util.ts
new file mode 100644
index 0000000..67e1b3b
--- /dev/null
+++ b/src/lib/modules/util.ts
@@ -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`;
+};
diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts
index 5d89306..ff3b0c7 100644
--- a/src/lib/server/db/file.ts
+++ b/src/lib/server/db/file.ts
@@ -27,6 +27,7 @@ export interface NewFileParams {
contentHmac: string | null;
contentType: string;
encContentIv: string;
+ encContentHash: string;
encName: string;
encNameIv: string;
encCreatedAt: string | null;
@@ -130,14 +131,15 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
return await db.transaction(
async (tx) => {
const unregisterFiles = async (parentId: number) => {
- const files = await tx
+ return await tx
.delete(file)
.where(and(eq(file.userId, userId), eq(file.parentId, parentId)))
- .returning({ path: file.path });
- return files.map(({ path }) => path);
+ .returning({ id: file.id, path: file.path });
};
- const unregisterDirectoryRecursively = async (directoryId: number): Promise => {
- const filePaths = await unregisterFiles(directoryId);
+ const unregisterDirectoryRecursively = async (
+ directoryId: number,
+ ): Promise<{ id: number; path: string }[]> => {
+ const files = await unregisterFiles(directoryId);
const subDirectories = await tx
.select({ id: directory.id })
.from(directory)
@@ -150,7 +152,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
if (deleteRes.changes === 0) {
throw new IntegrityError("Directory not found");
}
- return filePaths.concat(...subDirectoryFilePaths);
+ return files.concat(...subDirectoryFilePaths);
};
return await unregisterDirectoryRecursively(directoryId);
},
@@ -198,11 +200,12 @@ export const registerFile = async (params: NewFileParams) => {
userId: params.userId,
mekVersion: params.mekVersion,
hskVersion: params.hskVersion,
- contentHmac: params.contentHmac,
- contentType: params.contentType,
encDek: params.encDek,
dekVersion: params.dekVersion,
+ contentHmac: params.contentHmac,
+ contentType: params.contentType,
encContentIv: params.encContentIv,
+ encContentHash: params.encContentHash,
encName: { ciphertext: params.encName, iv: params.encNameIv },
encCreatedAt:
params.encCreatedAt && params.encCreatedAtIv
diff --git a/src/lib/server/db/schema/file.ts b/src/lib/server/db/schema/file.ts
index 9400ae9..a4d5e64 100644
--- a/src/lib/server/db/schema/file.ts
+++ b/src/lib/server/db/schema/file.ts
@@ -61,6 +61,7 @@ export const file = sqliteTable(
contentHmac: text("content_hmac"), // Base64
contentType: text("content_type").notNull(),
encContentIv: text("encrypted_content_iv").notNull(), // Base64
+ encContentHash: text("encrypted_content_hash").notNull(), // Base64
encName: ciphertext("encrypted_name").notNull(),
encCreatedAt: ciphertext("encrypted_created_at"),
encLastModifiedAt: ciphertext("encrypted_last_modified_at").notNull(),
diff --git a/src/lib/server/db/schema/hsk.ts b/src/lib/server/db/schema/hsk.ts
index b78c512..51f25cc 100644
--- a/src/lib/server/db/schema/hsk.ts
+++ b/src/lib/server/db/schema/hsk.ts
@@ -1,4 +1,5 @@
import { sqliteTable, text, integer, primaryKey, foreignKey } from "drizzle-orm/sqlite-core";
+import { client } from "./client";
import { mek } from "./mek";
import { user } from "./user";
@@ -32,7 +33,7 @@ export const hskLog = sqliteTable(
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),
+ actionBy: integer("action_by").references(() => client.id),
},
(t) => ({
ref: foreignKey({
diff --git a/src/lib/server/schemas/directory.ts b/src/lib/server/schemas/directory.ts
index 5f526aa..15a5886 100644
--- a/src/lib/server/schemas/directory.ts
+++ b/src/lib/server/schemas/directory.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
export const directoryInfoResponse = z.object({
metadata: z
.object({
+ parent: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
@@ -15,6 +16,11 @@ export const directoryInfoResponse = z.object({
});
export type DirectoryInfoResponse = z.infer;
+export const directoryDeleteResponse = z.object({
+ deletedFiles: z.number().int().positive().array(),
+});
+export type DirectoryDeleteResponse = z.infer;
+
export const directoryRenameRequest = z.object({
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
@@ -23,7 +29,7 @@ export const directoryRenameRequest = z.object({
export type DirectoryRenameRequest = z.infer;
export const directoryCreateRequest = z.object({
- parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
+ parent: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
diff --git a/src/lib/server/schemas/file.ts b/src/lib/server/schemas/file.ts
index f6ac315..781baf2 100644
--- a/src/lib/server/schemas/file.ts
+++ b/src/lib/server/schemas/file.ts
@@ -2,6 +2,7 @@ import mime from "mime";
import { z } from "zod";
export const fileInfoResponse = z.object({
+ parent: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
@@ -38,7 +39,7 @@ export const duplicateFileScanResponse = z.object({
export type DuplicateFileScanResponse = z.infer;
export const fileUploadRequest = z.object({
- parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
+ parent: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
diff --git a/src/lib/server/services/directory.ts b/src/lib/server/services/directory.ts
index aba0343..4dc14ce 100644
--- a/src/lib/server/services/directory.ts
+++ b/src/lib/server/services/directory.ts
@@ -19,9 +19,9 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
const directories = await getAllDirectoriesByParent(userId, directoryId);
const files = await getAllFilesByParent(userId, directoryId);
-
return {
metadata: directory && {
+ parentId: directory.parentId ?? ("root" as const),
mekVersion: directory.mekVersion,
encDek: directory.encDek,
dekVersion: directory.dekVersion,
@@ -34,8 +34,13 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
export const deleteDirectory = async (userId: number, directoryId: number) => {
try {
- const filePaths = await unregisterDirectory(userId, directoryId);
- filePaths.map((path) => unlink(path)); // Intended
+ const files = await unregisterDirectory(userId, directoryId);
+ return {
+ files: files.map(({ id, path }) => {
+ unlink(path); // Intended
+ return id;
+ }),
+ };
} catch (e) {
if (e instanceof IntegrityError && e.message === "Directory not found") {
error(404, "Invalid directory id");
diff --git a/src/lib/server/services/file.ts b/src/lib/server/services/file.ts
index cb5559b..ea01f16 100644
--- a/src/lib/server/services/file.ts
+++ b/src/lib/server/services/file.ts
@@ -1,8 +1,10 @@
import { error } from "@sveltejs/kit";
+import { createHash } from "crypto";
import { createReadStream, createWriteStream } from "fs";
import { mkdir, stat, unlink } from "fs/promises";
import { dirname } from "path";
-import { Readable, Writable } from "stream";
+import { Readable } from "stream";
+import { pipeline } from "stream/promises";
import { v4 as uuidv4 } from "uuid";
import { IntegrityError } from "$lib/server/db/error";
import {
@@ -22,6 +24,7 @@ export const getFileInformation = async (userId: number, fileId: number) => {
}
return {
+ parentId: file.parentId ?? ("root" as const),
mekVersion: file.mekVersion,
encDek: file.encDek,
dekVersion: file.dekVersion,
@@ -93,12 +96,13 @@ const safeUnlink = async (path: string) => {
};
export const uploadFile = async (
- params: Omit,
- encContentStream: ReadableStream,
+ params: Omit,
+ encContentStream: Readable,
+ encContentHash: Promise,
) => {
- const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
+ const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
- if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
+ if (params.dekVersion <= oneDayAgo || params.dekVersion >= oneMinuteLater) {
error(400, "Invalid DEK version");
}
@@ -106,20 +110,39 @@ export const uploadFile = async (
await mkdir(dirname(path), { recursive: true });
try {
- await encContentStream.pipeTo(
- Writable.toWeb(createWriteStream(path, { flags: "wx", mode: 0o600 })),
- );
+ const hashStream = createHash("sha256");
+ const [_, hash] = await Promise.all([
+ pipeline(
+ encContentStream,
+ async function* (source) {
+ for await (const chunk of source) {
+ hashStream.update(chunk);
+ yield chunk;
+ }
+ },
+ createWriteStream(path, { flags: "wx", mode: 0o600 }),
+ ),
+ encContentHash,
+ ]);
+ if (hashStream.digest("base64") != hash) {
+ throw new Error("Invalid checksum");
+ }
+
await registerFile({
...params,
path,
+ encContentHash: hash,
});
} catch (e) {
await safeUnlink(path);
- if (e instanceof IntegrityError) {
- if (e.message === "Inactive MEK version") {
- error(400, "Invalid MEK version");
- }
+ if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
+ error(400, "Invalid MEK version");
+ } else if (
+ e instanceof Error &&
+ (e.message === "Invalid request body" || e.message === "Invalid checksum")
+ ) {
+ error(400, "Invalid request body");
}
throw e;
}
diff --git a/src/lib/stores/file.ts b/src/lib/stores/file.ts
index 2debd57..61db95d 100644
--- a/src/lib/stores/file.ts
+++ b/src/lib/stores/file.ts
@@ -1,34 +1,49 @@
-import type { Writable } from "svelte/store";
+import { writable, type Writable } from "svelte/store";
-export type DirectoryInfo =
- | {
- id: "root";
- dataKey?: undefined;
- dataKeyVersion?: undefined;
- name?: undefined;
- subDirectoryIds: number[];
- fileIds: number[];
- }
- | {
- id: number;
- dataKey: CryptoKey;
- dataKeyVersion: Date;
- name: string;
- subDirectoryIds: number[];
- fileIds: number[];
- };
-
-export interface FileInfo {
- id: number;
- dataKey: CryptoKey;
- dataKeyVersion: Date;
- contentType: string;
- contentIv: string;
+export interface FileUploadStatus {
name: string;
- createdAt?: Date;
- lastModifiedAt: Date;
+ parentId: "root" | number;
+ status:
+ | "encryption-pending"
+ | "encrypting"
+ | "upload-pending"
+ | "uploading"
+ | "uploaded"
+ | "canceled"
+ | "error";
+ progress?: number;
+ rate?: number;
+ estimated?: number;
}
-export const directoryInfoStore = new Map<"root" | number, Writable>();
+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 fileInfoStore = new Map>();
+export const fileUploadStatusStore = writable[]>([]);
+
+export const fileDownloadStatusStore = writable[]>([]);
+
+export const isFileUploading = (
+ status: FileUploadStatus["status"],
+): status is "encryption-pending" | "encrypting" | "upload-pending" | "uploading" => {
+ return ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
+};
+
+export const isFileDownloading = (
+ status: FileDownloadStatus["status"],
+): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => {
+ return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
+};
diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte
index 642017a..6188520 100644
--- a/src/routes/(fullscreen)/file/[id]/+page.svelte
+++ b/src/routes/(fullscreen)/file/[id]/+page.svelte
@@ -1,66 +1,78 @@
@@ -69,23 +81,24 @@
-
+
+
{#snippet viewerLoading(message: string)}
{/snippet}
- {#if $info && contentType === "image"}
- {#if contentUrl}
-

+ {#if $info && viewerType === "image"}
+ {#if fileBlobUrl}
+

{:else}
{@render viewerLoading("이미지를 불러오고 있어요.")}
{/if}
- {:else if contentType === "video"}
- {#if contentUrl}
+ {:else if viewerType === "video"}
+ {#if fileBlobUrl}
-
+
{:else}
{@render viewerLoading("비디오를 불러오고 있어요.")}
{/if}
diff --git a/src/routes/(fullscreen)/file/[id]/+page.ts b/src/routes/(fullscreen)/file/[id]/+page.ts
index 45c696e..0521107 100644
--- a/src/routes/(fullscreen)/file/[id]/+page.ts
+++ b/src/routes/(fullscreen)/file/[id]/+page.ts
@@ -2,8 +2,6 @@ import { error } from "@sveltejs/kit";
import { z } from "zod";
import type { PageLoad } from "./$types";
-export const ssr = false; // Because of heic2any
-
export const load: PageLoad = async ({ params }) => {
const zodRes = z
.object({
diff --git a/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte b/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte
new file mode 100644
index 0000000..21774cd
--- /dev/null
+++ b/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte
@@ -0,0 +1,33 @@
+
+
+{#if $status && isFileDownloading($status.status)}
+
+
+ {#if $status.status === "download-pending"}
+ 다운로드를 기다리는 중
+ {:else if $status.status === "downloading"}
+ 다운로드하는 중
+ {:else if $status.status === "decryption-pending"}
+ 복호화를 기다리는 중
+ {:else if $status.status === "decrypting"}
+ 복호화하는 중
+ {/if}
+
+
+ {#if $status.status === "downloading"}
+ 전송됨
+ {Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
+ {/if}
+
+
+{/if}
diff --git a/src/routes/(fullscreen)/file/[id]/service.ts b/src/routes/(fullscreen)/file/[id]/service.ts
index fc97c3e..fcc5ce7 100644
--- a/src/routes/(fullscreen)/file/[id]/service.ts
+++ b/src/routes/(fullscreen)/file/[id]/service.ts
@@ -1,31 +1,14 @@
-import { decryptData } from "$lib/modules/crypto";
+import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file";
-export const requestFileDownload = (
+export const requestFileDownload = async (
fileId: number,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
- return new Promise
((resolve, reject) => {
- const xhr = new XMLHttpRequest();
- xhr.responseType = "arraybuffer";
+ const cache = await getFileCache(fileId);
+ if (cache) return cache;
- xhr.addEventListener("load", async () => {
- if (xhr.status !== 200) {
- reject(new Error("Failed to download file"));
- return;
- }
-
- const fileDecrypted = await decryptData(
- xhr.response as ArrayBuffer,
- fileEncryptedIv,
- dataKey,
- );
- resolve(fileDecrypted);
- });
-
- // TODO: Progress, ...
-
- xhr.open("GET", `/api/file/${fileId}/download`);
- xhr.send();
- });
+ const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey);
+ storeFileCache(fileId, fileBuffer); // Intended
+ return fileBuffer;
};
diff --git a/src/routes/(fullscreen)/file/downloads/+page.svelte b/src/routes/(fullscreen)/file/downloads/+page.svelte
new file mode 100644
index 0000000..a3ce387
--- /dev/null
+++ b/src/routes/(fullscreen)/file/downloads/+page.svelte
@@ -0,0 +1,29 @@
+
+
+
+ 진행 중인 다운로드
+
+
+
+
+
+ {#each downloadingFiles as status}
+
+ {/each}
+
+
diff --git a/src/routes/(fullscreen)/file/downloads/File.svelte b/src/routes/(fullscreen)/file/downloads/File.svelte
new file mode 100644
index 0000000..56ee319
--- /dev/null
+++ b/src/routes/(fullscreen)/file/downloads/File.svelte
@@ -0,0 +1,67 @@
+
+
+{#if $fileInfo}
+
+
+ {#if $status.status === "download-pending"}
+
+ {:else if $status.status === "downloading"}
+
+ {:else if $status.status === "decryption-pending"}
+
+ {:else if $status.status === "decrypting"}
+
+ {:else if $status.status === "decrypted"}
+
+ {:else if $status.status === "error"}
+
+ {/if}
+
+
+
+ {$fileInfo.name}
+
+
+ {#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}
+
+
+
+{/if}
diff --git a/src/routes/(fullscreen)/file/uploads/+page.svelte b/src/routes/(fullscreen)/file/uploads/+page.svelte
new file mode 100644
index 0000000..4e61868
--- /dev/null
+++ b/src/routes/(fullscreen)/file/uploads/+page.svelte
@@ -0,0 +1,29 @@
+
+
+
+ 진행 중인 업로드
+
+
+
+
+
+ {#each uploadingFiles as status}
+
+ {/each}
+
+
diff --git a/src/routes/(fullscreen)/file/uploads/File.svelte b/src/routes/(fullscreen)/file/uploads/File.svelte
new file mode 100644
index 0000000..1a228dc
--- /dev/null
+++ b/src/routes/(fullscreen)/file/uploads/File.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+ {#if $status.status === "encryption-pending"}
+
+ {:else if $status.status === "encrypting"}
+
+ {:else if $status.status === "upload-pending"}
+
+ {:else if $status.status === "uploading"}
+
+ {:else if $status.status === "uploaded"}
+
+ {:else if $status.status === "error"}
+
+ {/if}
+
+
+
+ {$status.name}
+
+
+ {#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}
+
+
+
diff --git a/src/routes/(fullscreen)/key/export/+page.svelte b/src/routes/(fullscreen)/key/export/+page.svelte
index 297d91d..6cd6ae6 100644
--- a/src/routes/(fullscreen)/key/export/+page.svelte
+++ b/src/routes/(fullscreen)/key/export/+page.svelte
@@ -1,5 +1,5 @@
+
+
+ 캐시 설정
+
+
+
+
+ {#if fileCache && fileCache.length > 0}
+
+
+
+ {fileCache.length}개 파일이 캐시되어 {formatFileSize(fileCacheTotalSize)}를 사용하고
+ 있어요.
+
+
캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.
+
+
+ {#each fileCache as { index, fileInfo }}
+
+ {/each}
+
+
+ {:else}
+
+
+ {#if fileCache}
+ 캐시된 파일이 없어요.
+ {:else}
+ 캐시 목록을 불러오고 있어요.
+ {/if}
+
+
+ {/if}
+
diff --git a/src/routes/(fullscreen)/settings/cache/File.svelte b/src/routes/(fullscreen)/settings/cache/File.svelte
new file mode 100644
index 0000000..f21445b
--- /dev/null
+++ b/src/routes/(fullscreen)/settings/cache/File.svelte
@@ -0,0 +1,46 @@
+
+
+
+ {#if $info}
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+ {#if $info}
+
{$info.name}
+ {:else}
+
삭제된 파일
+ {/if}
+
+ 읽음 {formatDate(index.lastRetrievedAt)} · {formatFileSize(index.size)}
+
+
+
+
diff --git a/src/routes/(fullscreen)/settings/cache/service.ts b/src/routes/(fullscreen)/settings/cache/service.ts
new file mode 100644
index 0000000..35b0251
--- /dev/null
+++ b/src/routes/(fullscreen)/settings/cache/service.ts
@@ -0,0 +1,5 @@
+import { deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
+
+export const deleteFileCache = async (fileId: number) => {
+ await doDeleteFileCache(fileId);
+};
diff --git a/src/routes/(main)/directory/[[id]]/+page.svelte b/src/routes/(main)/directory/[[id]]/+page.svelte
index 5361fd7..f8bd0c9 100644
--- a/src/routes/(main)/directory/[[id]]/+page.svelte
+++ b/src/routes/(main)/directory/[[id]]/+page.svelte
@@ -4,19 +4,20 @@
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components";
import { FloatingButton } from "$lib/components/buttons";
- import { getDirectoryInfo } from "$lib/modules/file";
- import { masterKeyStore, hmacSecretStore, type DirectoryInfo } from "$lib/stores";
+ import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
+ import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import CreateBottomSheet from "./CreateBottomSheet.svelte";
import CreateDirectoryModal from "./CreateDirectoryModal.svelte";
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";
import DirectoryEntries from "./DirectoryEntries";
import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte";
+ import DownloadStatusCard from "./DownloadStatusCard.svelte";
import DuplicateFileModal from "./DuplicateFileModal.svelte";
import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte";
+ import UploadStatusCard from "./UploadStatusCard.svelte";
import {
requestHmacSecretDownload,
requestDirectoryCreation,
- requestDuplicateFileScan,
requestFileUpload,
requestDirectoryEntryRename,
requestDirectoryEntryDeletion,
@@ -25,17 +26,12 @@
import IconAdd from "~icons/material-symbols/add";
- interface LoadedFile {
- file: File;
- fileBuffer: ArrayBuffer;
- fileSigned: string;
- }
-
let { data } = $props();
let info: Writable | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state();
- let loadedFile: LoadedFile | undefined = $state();
+ let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
+ let duplicatedFile: File | undefined = $state();
let selectedEntry: SelectedDirectoryEntry | undefined = $state();
let isCreateBottomSheetOpen = $state(false);
@@ -52,34 +48,33 @@
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
};
- const uploadFile = (loadedFile: LoadedFile) => {
- requestFileUpload(
- loadedFile.file,
- loadedFile.fileBuffer,
- loadedFile.fileSigned,
- data.id,
- $masterKeyStore?.get(1)!,
- $hmacSecretStore?.get(1)!,
- ).then(() => {
- info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
- });
- };
+ const uploadFile = () => {
+ const files = fileInput?.files;
+ if (!files || files.length === 0) return;
- const loadAndUploadFile = async () => {
- const file = fileInput?.files?.[0];
- if (!file) return;
+ for (const file of files) {
+ requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => {
+ return new Promise((resolve) => {
+ resolveForDuplicateFileModal = resolve;
+ duplicatedFile = file;
+ isDuplicateFileModalOpen = true;
+ });
+ })
+ .then((res) => {
+ if (!res) return;
+
+ // TODO: FIXME
+ info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
+ window.alert(`'${file.name}' 파일이 업로드되었어요.`);
+ })
+ .catch((e: Error) => {
+ // TODO: FIXME
+ console.error(e);
+ window.alert(`'${file.name}' 파일 업로드에 실패했어요.\n${e.message}`);
+ });
+ }
fileInput!.value = "";
-
- const scanRes = await requestDuplicateFileScan(file, $hmacSecretStore?.get(1)!);
- if (scanRes === null) {
- throw new Error("Failed to scan duplicate files");
- } else if (scanRes.isDuplicate) {
- loadedFile = { ...scanRes, file };
- isDuplicateFileModalOpen = true;
- } else {
- uploadFile({ ...scanRes, file });
- }
};
onMount(async () => {
@@ -97,7 +92,7 @@
파일
-
+
{#if data.id !== "root"}
@@ -106,6 +101,10 @@
{#if $info}
{@const topMargin = data.id === "root" ? "mt-4" : ""}
+
+ goto("/file/uploads")} />
+ goto("/file/downloads")} />
+
{#key $info}
{
+ resolveForDuplicateFileModal?.(false);
+ resolveForDuplicateFileModal = undefined;
+ duplicatedFile = undefined;
isDuplicateFileModalOpen = false;
- loadedFile = undefined;
}}
onDuplicateClick={() => {
- uploadFile(loadedFile!);
+ resolveForDuplicateFileModal?.(true);
+ resolveForDuplicateFileModal = undefined;
+ duplicatedFile = undefined;
isDuplicateFileModalOpen = false;
- loadedFile = undefined;
}}
/>
diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte
index ea8a984..e482e38 100644
--- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte
+++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte
@@ -1,11 +1,22 @@
-{#if info.subDirectoryIds.length + info.fileIds.length > 0}
-
- {#each subDirectoryInfos as subDirectory}
-
+{#if subDirectories.length + files.length > 0}
+
+ {#each subDirectories as { info }}
+
{/each}
- {#each fileInfos as file}
-
+ {#each files as file}
+ {#if file.type === "file"}
+
+ {:else}
+
+ {/if}
{/each}
{:else}
diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte
index c95ba6d..0dad51b 100644
--- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte
+++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte
@@ -1,7 +1,7 @@
+
+{#if isFileUploading($status.status)}
+
+
+
+
+
+
+ {$status.name}
+
+
+ {#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}
+
+
+
+{/if}
diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts
index 572378b..b797727 100644
--- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts
+++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts
@@ -1,22 +1,23 @@
-import { get, type Writable } from "svelte/store";
-import type { DirectoryInfo, FileInfo } from "$lib/stores";
-
export enum SortBy {
NAME_ASC,
NAME_DESC,
}
-type SortFunc = (a: DirectoryInfo | FileInfo | null, b: DirectoryInfo | FileInfo | null) => number;
+type SortFunc = (a?: string, b?: string) => number;
+
+const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
const sortByNameAsc: SortFunc = (a, b) => {
- if (a && b) return a.name!.localeCompare(b.name!);
+ if (a && b) return collator.compare(a, b);
+ if (a) return -1;
+ if (b) return 1;
return 0;
};
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
-export const sortEntries =
(
- entries: Writable[],
+export const sortEntries = (
+ entries: T[],
sortBy: SortBy = SortBy.NAME_ASC,
) => {
let sortFunc: SortFunc;
@@ -26,17 +27,5 @@ export const sortEntries = (
sortFunc = sortByNameDesc;
}
- entries.sort((a, b) => sortFunc(get(a), get(b)));
-};
-
-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();
- const hours = date.getHours();
- const minutes = date.getMinutes();
-
- return `${year}. ${month}. ${day}. ${pad2(hours)}:${pad2(minutes)}`;
+ entries.sort((a, b) => sortFunc(a.name, b.name));
};
diff --git a/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte b/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte
new file mode 100644
index 0000000..18bb159
--- /dev/null
+++ b/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte
@@ -0,0 +1,41 @@
+
+
+{#if downloadingFiles.length > 0}
+
+{/if}
diff --git a/src/routes/(main)/directory/[[id]]/DuplicateFileModal.svelte b/src/routes/(main)/directory/[[id]]/DuplicateFileModal.svelte
index 583fda8..6c9d9af 100644
--- a/src/routes/(main)/directory/[[id]]/DuplicateFileModal.svelte
+++ b/src/routes/(main)/directory/[[id]]/DuplicateFileModal.svelte
@@ -3,30 +3,28 @@
import { Button } from "$lib/components/buttons";
interface Props {
+ file: File | undefined;
onclose: () => void;
onDuplicateClick: () => void;
isOpen: boolean;
}
- let { onclose, onDuplicateClick, isOpen = $bindable() }: Props = $props();
+ let { file, onclose, onDuplicateClick, isOpen = $bindable() }: Props = $props();
-
-
-
이미 업로드된 파일이에요.
-
그래도 업로드할까요?
+ {#if file}
+ {@const { name } = file}
+ {@const nameShort = name.length > 20 ? `${name.slice(0, 20)}...` : name}
+
+
+
'{nameShort}' 파일이 있어요.
+
예전에 이미 업로드된 파일이에요. 그래도 업로드할까요?
+
+
+
+
+
-
-
-
-
-
+ {/if}
diff --git a/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte b/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte
new file mode 100644
index 0000000..1ac40b3
--- /dev/null
+++ b/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte
@@ -0,0 +1,39 @@
+
+
+{#if uploadingFiles.length > 0}
+
+{/if}
diff --git a/src/routes/(main)/directory/[[id]]/service.ts b/src/routes/(main)/directory/[[id]]/service.ts
index fa3fcfc..c9a62b2 100644
--- a/src/routes/(main)/directory/[[id]]/service.ts
+++ b/src/routes/(main)/directory/[[id]]/service.ts
@@ -1,23 +1,13 @@
-import ExifReader from "exifreader";
import { callGetApi, callPostApi } from "$lib/hooks";
import { storeHmacSecrets } from "$lib/indexedDB";
-import {
- encodeToBase64,
- generateDataKey,
- wrapDataKey,
- unwrapHmacSecret,
- encryptData,
- encryptString,
- signMessageHmac,
-} from "$lib/modules/crypto";
+import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
+import { deleteFileCache, uploadFile } from "$lib/modules/file";
import type {
DirectoryRenameRequest,
DirectoryCreateRequest,
FileRenameRequest,
- FileUploadRequest,
HmacSecretListResponse,
- DuplicateFileScanRequest,
- DuplicateFileScanResponse,
+ DirectoryDeleteResponse,
} from "$lib/server/schemas";
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
@@ -57,7 +47,7 @@ export const requestDirectoryCreation = async (
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
await callPostApi
("/api/directory/create", {
- parentId,
+ parent: parentId,
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion.toISOString(),
@@ -66,106 +56,14 @@ export const requestDirectoryCreation = async (
});
};
-export const requestDuplicateFileScan = async (file: File, hmacSecret: HmacSecret) => {
- const fileBuffer = await file.arrayBuffer();
- const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
- const res = await callPostApi("/api/file/scanDuplicates", {
- hskVersion: hmacSecret.version,
- contentHmac: fileSigned,
- });
- if (!res.ok) return null;
-
- const { files }: DuplicateFileScanResponse = await res.json();
- return {
- fileBuffer,
- fileSigned,
- isDuplicate: files.length > 0,
- };
-};
-
-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 -> 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);
-};
-
export const requestFileUpload = async (
file: File,
- fileBuffer: ArrayBuffer,
- fileSigned: string,
parentId: "root" | number,
- masterKey: MasterKey,
hmacSecret: HmacSecret,
+ masterKey: MasterKey,
+ onDuplicate: () => Promise,
) => {
- let createdAt = undefined;
- if (file.type.startsWith("image/")) {
- createdAt = extractExifDateTime(fileBuffer);
- }
-
- const { dataKey, dataKeyVersion } = await generateDataKey();
- 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);
-
- const form = new FormData();
- form.set(
- "metadata",
- JSON.stringify({
- parentId,
- mekVersion: masterKey.version,
- dek: await wrapDataKey(dataKey, masterKey.key),
- dekVersion: dataKeyVersion.toISOString(),
- hskVersion: hmacSecret.version,
- contentHmac: fileSigned,
- contentType: file.type,
- contentIv: fileEncrypted.iv,
- name: nameEncrypted.ciphertext,
- nameIv: nameEncrypted.iv,
- createdAt: createdAtEncrypted?.ciphertext,
- createdAtIv: createdAtEncrypted?.iv,
- lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
- lastModifiedAtIv: lastModifiedAtEncrypted.iv,
- } satisfies FileUploadRequest),
- );
- form.set("content", new Blob([fileEncrypted.ciphertext]));
-
- return new Promise((resolve, reject) => {
- // TODO: Progress, Scheduling, ...
-
- const xhr = new XMLHttpRequest();
- xhr.addEventListener("load", () => {
- if (xhr.status === 200) {
- resolve();
- } else {
- reject(new Error(xhr.responseText));
- }
- });
-
- xhr.open("POST", "/api/file/upload");
- xhr.send(form);
- });
+ return await uploadFile(file, parentId, hmacSecret, masterKey, onDuplicate);
};
export const requestDirectoryEntryRename = async (
@@ -190,5 +88,15 @@ export const requestDirectoryEntryRename = async (
};
export const requestDirectoryEntryDeletion = async (entry: SelectedDirectoryEntry) => {
- await callPostApi(`/api/${entry.type}/${entry.id}/delete`);
+ const res = await callPostApi(`/api/${entry.type}/${entry.id}/delete`);
+ if (!res.ok) return false;
+
+ if (entry.type === "directory") {
+ const { deletedFiles }: DirectoryDeleteResponse = await res.json();
+ await Promise.all(deletedFiles.map(deleteFileCache));
+ return true;
+ } else {
+ await deleteFileCache(entry.id);
+ return true;
+ }
};
diff --git a/src/routes/(main)/menu/+page.svelte b/src/routes/(main)/menu/+page.svelte
index deade3d..13ccb92 100644
--- a/src/routes/(main)/menu/+page.svelte
+++ b/src/routes/(main)/menu/+page.svelte
@@ -1,8 +1,9 @@
+
+
+
+
+
+
+
+ {@render children?.()}
+
+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 091c034..e4bca97 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,11 +1,28 @@
+
+
{@render children()}
diff --git a/src/routes/api/directory/[id]/+server.ts b/src/routes/api/directory/[id]/+server.ts
index b8cea7f..8189160 100644
--- a/src/routes/api/directory/[id]/+server.ts
+++ b/src/routes/api/directory/[id]/+server.ts
@@ -20,6 +20,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
return json(
directoryInfoResponse.parse({
metadata: metadata && {
+ parent: metadata.parentId,
mekVersion: metadata.mekVersion,
dek: metadata.encDek,
dekVersion: metadata.dekVersion.toISOString(),
diff --git a/src/routes/api/directory/[id]/delete/+server.ts b/src/routes/api/directory/[id]/delete/+server.ts
index 4873912..4d29fd8 100644
--- a/src/routes/api/directory/[id]/delete/+server.ts
+++ b/src/routes/api/directory/[id]/delete/+server.ts
@@ -1,6 +1,7 @@
-import { error, text } from "@sveltejs/kit";
+import { error, json } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
+import { directoryDeleteResponse, type DirectoryDeleteResponse } from "$lib/server/schemas";
import { deleteDirectory } from "$lib/server/services/directory";
import type { RequestHandler } from "./$types";
@@ -15,6 +16,8 @@ export const POST: RequestHandler = async ({ locals, params }) => {
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
- await deleteDirectory(userId, id);
- return text("Directory deleted", { headers: { "Content-Type": "text/plain" } });
+ const { files } = await deleteDirectory(userId, id);
+ return json(
+ directoryDeleteResponse.parse({ deletedFiles: files } satisfies DirectoryDeleteResponse),
+ );
};
diff --git a/src/routes/api/directory/create/+server.ts b/src/routes/api/directory/create/+server.ts
index 2af0c3c..07711fc 100644
--- a/src/routes/api/directory/create/+server.ts
+++ b/src/routes/api/directory/create/+server.ts
@@ -9,11 +9,11 @@ export const POST: RequestHandler = async ({ locals, request }) => {
const zodRes = directoryCreateRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
- const { parentId, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
+ const { parent, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
await createDirectory({
userId,
- parentId,
+ parentId: parent,
mekVersion,
encDek: dek,
dekVersion: new Date(dekVersion),
diff --git a/src/routes/api/file/[id]/+server.ts b/src/routes/api/file/[id]/+server.ts
index ba47fe9..892f62b 100644
--- a/src/routes/api/file/[id]/+server.ts
+++ b/src/routes/api/file/[id]/+server.ts
@@ -17,6 +17,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
const { id } = zodRes.data;
const {
+ parentId,
mekVersion,
encDek,
dekVersion,
@@ -28,6 +29,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
} = await getFileInformation(userId, id);
return json(
fileInfoResponse.parse({
+ parent: parentId,
mekVersion,
dek: encDek,
dekVersion: dekVersion.toISOString(),
diff --git a/src/routes/api/file/upload/+server.ts b/src/routes/api/file/upload/+server.ts
index 0b72402..a69df0c 100644
--- a/src/routes/api/file/upload/+server.ts
+++ b/src/routes/api/file/upload/+server.ts
@@ -1,23 +1,18 @@
+import Busboy from "@fastify/busboy";
import { error, text } from "@sveltejs/kit";
+import { Readable, Writable } from "stream";
import { authorize } from "$lib/server/modules/auth";
import { fileUploadRequest } from "$lib/server/schemas";
import { uploadFile } from "$lib/server/services/file";
import type { RequestHandler } from "./$types";
-export const POST: RequestHandler = async ({ locals, request }) => {
- const { userId } = await authorize(locals, "activeClient");
+type FileMetadata = Parameters[0];
- const form = await request.formData();
- const metadata = form.get("metadata");
- const content = form.get("content");
- if (typeof metadata !== "string" || !(content instanceof File)) {
- error(400, "Invalid request body");
- }
-
- const zodRes = fileUploadRequest.safeParse(JSON.parse(metadata));
+const parseFileMetadata = (userId: number, json: string) => {
+ const zodRes = fileUploadRequest.safeParse(JSON.parse(json));
if (!zodRes.success) error(400, "Invalid request body");
const {
- parentId,
+ parent,
mekVersion,
dek,
dekVersion,
@@ -35,25 +30,77 @@ export const POST: RequestHandler = async ({ locals, request }) => {
if ((createdAt && !createdAtIv) || (!createdAt && createdAtIv))
error(400, "Invalid request body");
- await uploadFile(
- {
- userId,
- parentId,
- mekVersion,
- encDek: dek,
- dekVersion: new Date(dekVersion),
- hskVersion,
- contentHmac,
- contentType,
- encContentIv: contentIv,
- encName: name,
- encNameIv: nameIv,
- encCreatedAt: createdAt ?? null,
- encCreatedAtIv: createdAtIv ?? null,
- encLastModifiedAt: lastModifiedAt,
- encLastModifiedAtIv: lastModifiedAtIv,
- },
- content.stream(),
- );
- return text("File uploaded", { headers: { "Content-Type": "text/plain" } });
+ return {
+ userId,
+ parentId: parent,
+ mekVersion,
+ encDek: dek,
+ dekVersion: new Date(dekVersion),
+ hskVersion,
+ contentHmac,
+ contentType,
+ encContentIv: contentIv,
+ encName: name,
+ encNameIv: nameIv,
+ encCreatedAt: createdAt ?? null,
+ encCreatedAtIv: createdAtIv ?? null,
+ encLastModifiedAt: lastModifiedAt,
+ encLastModifiedAtIv: lastModifiedAtIv,
+ } satisfies FileMetadata;
+};
+
+export const POST: RequestHandler = async ({ locals, request }) => {
+ const { userId } = await authorize(locals, "activeClient");
+
+ const contentType = request.headers.get("Content-Type");
+ if (!contentType?.startsWith("multipart/form-data") || !request.body) {
+ error(400, "Invalid request body");
+ }
+
+ return new Promise((resolve, reject) => {
+ const bb = Busboy({ headers: { "content-type": contentType } });
+ const handler =
+ (f: (...args: T) => Promise) =>
+ (...args: T) => {
+ f(...args).catch(reject);
+ };
+
+ let metadata: FileMetadata | null = null;
+ let content: Readable | null = null;
+ const checksum = new Promise((resolveChecksum, rejectChecksum) => {
+ bb.on(
+ "field",
+ handler(async (fieldname, val) => {
+ if (fieldname === "metadata") {
+ if (!metadata) {
+ // Ignore subsequent metadata fields
+ metadata = parseFileMetadata(userId, val);
+ }
+ } else if (fieldname === "checksum") {
+ resolveChecksum(val); // Ignore subsequent checksum fields
+ } else {
+ error(400, "Invalid request body");
+ }
+ }),
+ );
+ bb.on(
+ "file",
+ handler(async (fieldname, file) => {
+ if (fieldname !== "content") error(400, "Invalid request body");
+ if (!metadata || content) error(400, "Invalid request body");
+ content = file;
+
+ await uploadFile(metadata, content, checksum);
+ resolve(text("File uploaded", { headers: { "Content-Type": "text/plain" } }));
+ }),
+ );
+ bb.on("finish", () => rejectChecksum(new Error("Invalid request body")));
+ bb.on("error", (e) => {
+ content?.emit("error", e) ?? reject(e);
+ rejectChecksum(e);
+ });
+ });
+
+ request.body!.pipeTo(Writable.toWeb(bb)).catch(() => {}); // busboy will handle the error
+ });
};