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/0001_blushing_alice.sql b/drizzle/0001_blushing_alice.sql new file mode 100644 index 0000000..f68ba02 --- /dev/null +++ b/drizzle/0001_blushing_alice.sql @@ -0,0 +1,2 @@ +ALTER TABLE `file` ADD `encrypted_created_at` text;--> statement-breakpoint +ALTER TABLE `file` ADD `encrypted_last_modified_at` text NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..3425d7b --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1301 @@ +{ + "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 e77a385..65be42a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1736704436996, "tag": "0000_unknown_stark_industries", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1736720831242, + "tag": "0001_blushing_alice", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 996e9f8..3a4adf2 100644 --- a/package.json +++ b/package.json @@ -27,16 +27,19 @@ "@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", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.46.1", "eslint-plugin-tailwindcss": "^3.17.5", + "exifreader": "^4.26.0", "file-saver": "^2.0.5", "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", @@ -49,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 e6e96c5..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 @@ -78,6 +84,9 @@ importers: eslint-plugin-tailwindcss: specifier: ^3.17.5 version: 3.17.5(tailwindcss@3.4.17) + exifreader: + specifier: ^4.26.0 + version: 4.26.0 file-saver: specifier: ^2.0.5 version: 2.0.5 @@ -90,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 @@ -599,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'} @@ -907,6 +922,10 @@ packages: resolution: {integrity: sha512-fzmjU8CHK853V/avYZAvuVut3ZTfwN5YtMaoi+X9Y9MA9keaWNHC3zEQ9zvyX/7Hj+5JkNyK1l7TOR2hevHB6Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@xmldom/xmldom@0.9.6': + resolution: {integrity: sha512-Su4xcxR0CPGwlDHNmVP09fqET9YxbyDXHaSob6JlBH7L6reTYaeim6zbk9o08UarO0L5GTRo3uzl0D+9lSxmvw==} + engines: {node: '>=14.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -962,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} @@ -969,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'} @@ -1050,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'} @@ -1104,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'} @@ -1343,6 +1376,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + exifreader@4.26.0: + resolution: {integrity: sha512-nNN9B0oaXTOpArdnIdJBAro2Sa620m7wMjMA5Xy1rcua0EYHVjzGKM5syBOWDqIG2Qay6Pes/5FOdj65hvZ9Vw==} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -1396,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==} @@ -1603,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'} @@ -1703,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'} @@ -1897,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==} @@ -2247,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==} @@ -2533,6 +2601,8 @@ snapshots: dependencies: levn: 0.4.1 + '@fastify/busboy@3.1.1': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -2852,6 +2922,9 @@ snapshots: '@typescript-eslint/types': 8.19.1 eslint-visitor-keys: 4.2.0 + '@xmldom/xmldom@0.9.6': + optional: true + acorn-jsx@5.3.2(acorn@8.14.0): dependencies: acorn: 8.14.0 @@ -2898,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 @@ -2908,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: {} @@ -2995,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: {} @@ -3031,6 +3118,8 @@ snapshots: deepmerge@4.3.1: {} + delayed-stream@1.0.0: {} + detect-libc@2.0.3: {} devalue@5.1.1: {} @@ -3275,6 +3364,10 @@ snapshots: esutils@2.0.3: {} + exifreader@4.26.0: + optionalDependencies: + '@xmldom/xmldom': 0.9.6 + expand-template@2.0.3: {} fast-deep-equal@3.1.3: {} @@ -3323,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: {} @@ -3494,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: {} @@ -3578,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 @@ -3701,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 @@ -4067,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/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 2e25399..0000000 --- a/src/lib/modules/file.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { writable, type Writable } from "svelte/store"; -import { callGetApi } from "$lib/hooks"; -import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; -import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas"; -import { - directoryInfoStore, - fileInfoStore, - type DirectoryInfo, - type FileInfo, -} from "$lib/stores/file"; - -const fetchDirectoryInfo = async ( - directoryId: "root" | number, - masterKey: CryptoKey, - infoStore: Writable, -) => { - const res = await callGetApi(`/api/directory/${directoryId}`); - if (!res.ok) throw new Error("Failed to fetch directory information"); - const { metadata, subDirectories, files }: DirectoryInfoResponse = await res.json(); - - let newInfo: DirectoryInfo; - if (directoryId === "root") { - newInfo = { - id: "root", - subDirectoryIds: subDirectories, - fileIds: files, - }; - } else { - const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey); - newInfo = { - id: directoryId, - dataKey, - dataKeyVersion: new Date(metadata!.dekVersion), - name: await decryptString(metadata!.name, metadata!.nameIv, dataKey), - subDirectoryIds: subDirectories, - fileIds: files, - }; - } - - infoStore.update(() => newInfo); -}; - -export const getDirectoryInfo = (directoryId: "root" | number, masterKey: CryptoKey) => { - // TODO: MEK rotation - - let info = directoryInfoStore.get(directoryId); - if (!info) { - info = writable(null); - directoryInfoStore.set(directoryId, info); - } - - fetchDirectoryInfo(directoryId, masterKey, info); - return info; -}; - -const fetchFileInfo = async ( - fileId: number, - masterKey: CryptoKey, - infoStore: Writable, -) => { - const res = await callGetApi(`/api/file/${fileId}`); - if (!res.ok) throw new Error("Failed to fetch file information"); - const metadata: FileInfoResponse = await res.json(); - - const { dataKey } = await unwrapDataKey(metadata.dek, masterKey); - const newInfo: FileInfo = { - id: fileId, - dataKey, - dataKeyVersion: new Date(metadata.dekVersion), - contentType: metadata.contentType, - contentIv: metadata.contentIv, - name: await decryptString(metadata.name, metadata.nameIv, dataKey), - }; - - infoStore.update(() => newInfo); -}; - -export const getFileInfo = (fileId: number, masterKey: CryptoKey) => { - // TODO: MEK rotation - - let info = fileInfoStore.get(fileId); - if (!info) { - info = writable(null); - fileInfoStore.set(fileId, info); - } - - fetchFileInfo(fileId, masterKey, info); - return info; -}; 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..2518c7f --- /dev/null +++ b/src/lib/modules/file/upload.ts @@ -0,0 +1,225 @@ +import axios from "axios"; +import ExifReader from "exifreader"; +import { limitFunction } from "p-limit"; +import { writable, type Writable } from "svelte/store"; +import { + encodeToBase64, + generateDataKey, + wrapDataKey, + encryptData, + encryptString, + signMessageHmac, +} from "$lib/modules/crypto"; +import type { + DuplicateFileScanRequest, + DuplicateFileScanResponse, + FileUploadRequest, +} from "$lib/server/schemas"; +import { + fileUploadStatusStore, + type MasterKey, + type HmacSecret, + type FileUploadStatus, +} from "$lib/stores"; + +const requestDuplicateFileScan = limitFunction( + async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise) => { + 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 nameEncrypted = await encryptString(file.name, dataKey); + const createdAtEncrypted = + createdAt && (await encryptString(createdAt.getTime().toString(), dataKey)); + const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey); + + status.update((value) => { + value.status = "upload-pending"; + return value; + }); + + return { + dataKeyWrapped, + dataKeyVersion, + fileEncrypted, + fileType, + nameEncrypted, + createdAtEncrypted, + lastModifiedAtEncrypted, + }; + }, + { concurrency: 4 }, +); + +const requestFileUpload = limitFunction( + async (status: Writable, 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, + fileEncrypted, + fileType, + nameEncrypted, + createdAtEncrypted, + lastModifiedAtEncrypted, + } = await encryptFile(status, file, fileBuffer, masterKey); + + const form = new FormData(); + form.set( + "metadata", + JSON.stringify({ + parent: parentId, + mekVersion: masterKey.version, + dek: dataKeyWrapped, + dekVersion: dataKeyVersion.toISOString(), + hskVersion: hmacSecret.version, + contentHmac: fileSigned, + contentType: fileType, + contentIv: fileEncrypted.iv, + name: nameEncrypted.ciphertext, + nameIv: nameEncrypted.iv, + createdAt: createdAtEncrypted?.ciphertext, + createdAtIv: createdAtEncrypted?.iv, + lastModifiedAt: lastModifiedAtEncrypted.ciphertext, + lastModifiedAtIv: lastModifiedAtEncrypted.iv, + } as FileUploadRequest), + ); + form.set("content", new Blob([fileEncrypted.ciphertext])); + + await requestFileUpload(status, form); + return true; + } catch (e) { + status.update((value) => { + value.status = "error"; + return value; + }); + throw e; + } +}; diff --git a/src/lib/modules/filesystem.ts b/src/lib/modules/filesystem.ts new file mode 100644 index 0000000..7313cb5 --- /dev/null +++ b/src/lib/modules/filesystem.ts @@ -0,0 +1,206 @@ +import { get, writable, type Writable } from "svelte/store"; +import { callGetApi } from "$lib/hooks"; +import { + getDirectoryInfos as getDirectoryInfosFromIndexedDB, + getDirectoryInfo as getDirectoryInfoFromIndexedDB, + storeDirectoryInfo, + deleteDirectoryInfo, + getFileInfos as getFileInfosFromIndexedDB, + getFileInfo as getFileInfoFromIndexedDB, + storeFileInfo, + deleteFileInfo, + type DirectoryId, +} from "$lib/indexedDB"; +import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; +import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas"; + +export type DirectoryInfo = + | { + id: "root"; + dataKey?: undefined; + dataKeyVersion?: undefined; + name?: undefined; + subDirectoryIds: number[]; + fileIds: number[]; + } + | { + id: number; + dataKey?: CryptoKey; + dataKeyVersion?: Date; + name: string; + subDirectoryIds: number[]; + fileIds: number[]; + }; + +export interface FileInfo { + id: number; + dataKey?: CryptoKey; + dataKeyVersion?: Date; + contentType: string; + contentIv?: string; + name: string; + createdAt?: Date; + lastModifiedAt: Date; +} + +const directoryInfoStore = new Map>(); +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); + } 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); + } 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 db6b881..a42235b 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -28,6 +28,10 @@ export interface NewFileParams { encContentIv: string; encName: string; encNameIv: string; + encCreatedAt: string | null; + encCreatedAtIv: string | null; + encLastModifiedAt: string; + encLastModifiedAtIv: string; } export const registerDirectory = async (params: NewDirectoryParams) => { @@ -125,14 +129,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) @@ -145,7 +150,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); }, @@ -154,7 +159,12 @@ export const unregisterDirectory = async (userId: number, directoryId: number) = }; export const registerFile = async (params: NewFileParams) => { - if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) { + if ( + (params.hskVersion && !params.contentHmac) || + (!params.hskVersion && params.contentHmac) || + (params.encCreatedAt && !params.encCreatedAtIv) || + (!params.encCreatedAt && params.encCreatedAtIv) + ) { throw new Error("Invalid arguments"); } @@ -194,6 +204,14 @@ export const registerFile = async (params: NewFileParams) => { dekVersion: params.dekVersion, encContentIv: params.encContentIv, encName: { ciphertext: params.encName, iv: params.encNameIv }, + encCreatedAt: + params.encCreatedAt && params.encCreatedAtIv + ? { ciphertext: params.encCreatedAt, iv: params.encCreatedAtIv } + : null, + encLastModifiedAt: { + ciphertext: params.encLastModifiedAt, + iv: params.encLastModifiedAtIv, + }, }) .returning({ id: file.id }); const { id: fileId } = newFiles[0]!; diff --git a/src/lib/server/db/schema/file.ts b/src/lib/server/db/schema/file.ts index 7ac0b77..65c5471 100644 --- a/src/lib/server/db/schema/file.ts +++ b/src/lib/server/db/schema/file.ts @@ -61,6 +61,8 @@ export const file = sqliteTable( contentType: text("content_type").notNull(), encContentIv: text("encrypted_content_iv").notNull(), // Base64 encName: ciphertext("encrypted_name").notNull(), + encCreatedAt: ciphertext("encrypted_created_at"), + encLastModifiedAt: ciphertext("encrypted_last_modified_at").notNull(), }, (t) => ({ ref1: 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 f73b299..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(), @@ -12,6 +13,10 @@ export const fileInfoResponse = z.object({ contentIv: z.string().base64().nonempty(), name: z.string().base64().nonempty(), nameIv: z.string().base64().nonempty(), + createdAt: z.string().base64().nonempty().optional(), + createdAtIv: z.string().base64().nonempty().optional(), + lastModifiedAt: z.string().base64().nonempty(), + lastModifiedAtIv: z.string().base64().nonempty(), }); export type FileInfoResponse = z.infer; @@ -34,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(), @@ -47,5 +52,9 @@ export const fileUploadRequest = z.object({ contentIv: z.string().base64().nonempty(), name: z.string().base64().nonempty(), nameIv: z.string().base64().nonempty(), + createdAt: z.string().base64().nonempty().optional(), + createdAtIv: z.string().base64().nonempty().optional(), + lastModifiedAt: z.string().base64().nonempty(), + lastModifiedAtIv: z.string().base64().nonempty(), }); export type FileUploadRequest = z.infer; 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 7599939..3589bed 100644 --- a/src/lib/server/services/file.ts +++ b/src/lib/server/services/file.ts @@ -2,7 +2,8 @@ import { error } from "@sveltejs/kit"; 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,12 +23,15 @@ export const getFileInformation = async (userId: number, fileId: number) => { } return { + parentId: file.parentId ?? ("root" as const), mekVersion: file.mekVersion, encDek: file.encDek, dekVersion: file.dekVersion, contentType: file.contentType, encContentIv: file.encContentIv, encName: file.encName, + encCreatedAt: file.encCreatedAt, + encLastModifiedAt: file.encLastModifiedAt, }; }; @@ -92,11 +96,11 @@ const safeUnlink = async (path: string) => { export const uploadFile = async ( params: Omit, - encContentStream: ReadableStream, + encContentStream: Readable, ) => { - 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"); } @@ -104,9 +108,7 @@ export const uploadFile = async ( await mkdir(dirname(path), { recursive: true }); try { - await encContentStream.pipeTo( - Writable.toWeb(createWriteStream(path, { flags: "wx", mode: 0o600 })), - ); + await pipeline(encContentStream, createWriteStream(path, { flags: "wx", mode: 0o600 })); await registerFile({ ...params, path, @@ -114,10 +116,8 @@ export const uploadFile = async ( } 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"); } throw e; } diff --git a/src/lib/stores/file.ts b/src/lib/stores/file.ts index 24997da..61db95d 100644 --- a/src/lib/stores/file.ts +++ b/src/lib/stores/file.ts @@ -1,31 +1,49 @@ -import type { Writable } from "svelte/store"; +import { writable, type Writable } from "svelte/store"; -export type DirectoryInfo = - | { - id: "root"; - dataKey?: undefined; - dataKeyVersion?: undefined; - name?: undefined; - subDirectoryIds: number[]; - fileIds: number[]; - } - | { - id: number; - dataKey: CryptoKey; - dataKeyVersion: Date; - name: string; - subDirectoryIds: number[]; - fileIds: number[]; - }; - -export interface FileInfo { - id: number; - dataKey: CryptoKey; - dataKeyVersion: Date; - contentType: string; - contentIv: string; +export interface FileUploadStatus { name: string; + 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 const fileInfoStore = new Map>(); +export interface FileDownloadStatus { + id: number; + status: + | "download-pending" + | "downloading" + | "decryption-pending" + | "decrypting" + | "decrypted" + | "canceled" + | "error"; + progress?: number; + rate?: number; + estimated?: number; + result?: ArrayBuffer; +} + +export const fileUploadStatusStore = writable[]>([]); + +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)}

{message}

{/snippet} - {#if $info && contentType === "image"} - {#if contentUrl} - {$info.name} + {#if $info && viewerType === "image"} + {#if fileBlobUrl} + {$info.name} {: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..a29b147 --- /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..9f59e3f --- /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..e2a187c 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 c9ef1d8..0dad51b 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte @@ -1,6 +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 2ad5941..e1fc716 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts @@ -1,22 +1,21 @@ -import { get, type Writable } from "svelte/store"; -import type { DirectoryInfo, FileInfo } from "$lib/stores"; - export enum SortBy { NAME_ASC, NAME_DESC, } -type SortFunc = (a: DirectoryInfo | FileInfo | null, b: DirectoryInfo | FileInfo | null) => number; +type SortFunc = (a?: string, b?: string) => number; const sortByNameAsc: SortFunc = (a, b) => { - if (a && b) return a.name!.localeCompare(b.name!); + if (a && b) return a.localeCompare(b); + if (a) return -1; + if (b) return 1; return 0; }; const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b); -export const sortEntries = ( - entries: Writable[], +export const sortEntries = ( + entries: T[], sortBy: SortBy = SortBy.NAME_ASC, ) => { let sortFunc: SortFunc; @@ -26,5 +25,5 @@ export const sortEntries = ( sortFunc = sortByNameDesc; } - entries.sort((a, b) => sortFunc(get(a), get(b))); + 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 32575fb..c9a62b2 100644 --- a/src/routes/(main)/directory/[[id]]/service.ts +++ b/src/routes/(main)/directory/[[id]]/service.ts @@ -1,22 +1,13 @@ 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"; @@ -56,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(), @@ -65,68 +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, - }; -}; - export const requestFileUpload = async ( file: File, - fileBuffer: ArrayBuffer, - fileSigned: string, parentId: "root" | number, - masterKey: MasterKey, hmacSecret: HmacSecret, + masterKey: MasterKey, + onDuplicate: () => Promise, ) => { - const { dataKey, dataKeyVersion } = await generateDataKey(); - const nameEncrypted = await encryptString(file.name, dataKey); - const fileEncrypted = await encryptData(fileBuffer, 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, - } 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 ( @@ -151,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 efb7a9e..13ccb92 100644 --- a/src/routes/(main)/menu/+page.svelte +++ b/src/routes/(main)/menu/+page.svelte @@ -1,10 +1,19 @@ @@ -15,15 +24,27 @@

{data.nickname}

+
+

설정

+ goto("/settings/cache")} + icon={IconStorage} + iconColor="text-green-500" + > + 캐시 + +

보안

- goto("/auth/changePassword")}> -
-
- -
-

비밀번호 바꾸기

-
-
+ goto("/auth/changePassword")} + icon={IconPassword} + iconColor="text-blue-500" + > + 비밀번호 바꾸기 + + + 로그아웃 +
diff --git a/src/routes/(main)/menu/MenuEntryButton.svelte b/src/routes/(main)/menu/MenuEntryButton.svelte new file mode 100644 index 0000000..eb236c9 --- /dev/null +++ b/src/routes/(main)/menu/MenuEntryButton.svelte @@ -0,0 +1,25 @@ + + + +
+
+ +
+

+ {@render children?.()} +

+
+
diff --git a/src/routes/(main)/menu/service.ts b/src/routes/(main)/menu/service.ts new file mode 100644 index 0000000..32dd4fd --- /dev/null +++ b/src/routes/(main)/menu/service.ts @@ -0,0 +1,6 @@ +import { callPostApi } from "$lib/hooks"; + +export const requestLogout = async () => { + const res = await callPostApi("/api/auth/logout"); + return res.ok; +}; 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 cb5d0de..892f62b 100644 --- a/src/routes/api/file/[id]/+server.ts +++ b/src/routes/api/file/[id]/+server.ts @@ -16,10 +16,20 @@ export const GET: RequestHandler = async ({ locals, params }) => { if (!zodRes.success) error(400, "Invalid path parameters"); const { id } = zodRes.data; - const { mekVersion, encDek, dekVersion, contentType, encContentIv, encName } = - await getFileInformation(userId, id); + const { + parentId, + mekVersion, + encDek, + dekVersion, + contentType, + encContentIv, + encName, + encCreatedAt, + encLastModifiedAt, + } = await getFileInformation(userId, id); return json( fileInfoResponse.parse({ + parent: parentId, mekVersion, dek: encDek, dekVersion: dekVersion.toISOString(), @@ -27,6 +37,10 @@ export const GET: RequestHandler = async ({ locals, params }) => { contentIv: encContentIv, name: encName.ciphertext, nameIv: encName.iv, + createdAt: encCreatedAt?.ciphertext, + createdAtIv: encCreatedAt?.iv, + lastModifiedAt: encLastModifiedAt.ciphertext, + lastModifiedAtIv: encLastModifiedAt.iv, } satisfies FileInfoResponse), ); }; diff --git a/src/routes/api/file/upload/+server.ts b/src/routes/api/file/upload/+server.ts index 1bc9ce2..0e8c082 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, @@ -27,23 +22,73 @@ export const POST: RequestHandler = async ({ locals, request }) => { contentIv, name, nameIv, + createdAt, + createdAtIv, + lastModifiedAt, + lastModifiedAtIv, } = zodRes.data; + 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, - }, - 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; + + bb.on( + "field", + handler(async (fieldname, val) => { + if (fieldname !== "metadata") error(400, "Invalid request body"); + if (metadata || content) error(400, "Invalid request body"); + metadata = parseFileMetadata(userId, val); + }), + ); + 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); + resolve(text("File uploaded", { headers: { "Content-Type": "text/plain" } })); + }), + ); + bb.on("error", (e) => content?.emit("error", e) ?? reject(e)); + + request.body!.pipeTo(Writable.toWeb(bb)).catch(() => {}); // busboy will handle the error + }); };