mirror of
https://github.com/kmc7468/arkvault.git
synced 2025-12-16 15:08:46 +00:00
Merge branch 'dev' into add-file-category
This commit is contained in:
@@ -13,6 +13,8 @@ COPY . .
|
|||||||
RUN pnpm install --offline
|
RUN pnpm install --offline
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
|
RUN sed -i "s/http\.createServer()/http.createServer({ requestTimeout: 0 })/g" ./build/index.js
|
||||||
|
|
||||||
# Deploy Stage
|
# Deploy Stage
|
||||||
FROM base
|
FROM base
|
||||||
RUN pnpm fetch --prod
|
RUN pnpm fetch --prod
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ CREATE TABLE `file` (
|
|||||||
`content_hmac` text,
|
`content_hmac` text,
|
||||||
`content_type` text NOT NULL,
|
`content_type` text NOT NULL,
|
||||||
`encrypted_content_iv` text NOT NULL,
|
`encrypted_content_iv` text NOT NULL,
|
||||||
|
`encrypted_content_hash` text NOT NULL,
|
||||||
`encrypted_name` text NOT NULL,
|
`encrypted_name` text NOT NULL,
|
||||||
|
`encrypted_created_at` text,
|
||||||
|
`encrypted_last_modified_at` text NOT NULL,
|
||||||
FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action,
|
FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action,
|
FOREIGN KEY (`user_id`,`master_encryption_key_version`) REFERENCES `master_encryption_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action,
|
||||||
@@ -94,7 +97,7 @@ CREATE TABLE `hmac_secret_key_log` (
|
|||||||
`action` text NOT NULL,
|
`action` text NOT NULL,
|
||||||
`action_by` integer,
|
`action_by` integer,
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
FOREIGN KEY (`action_by`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
|
FOREIGN KEY (`action_by`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
FOREIGN KEY (`user_id`,`hmac_secret_key_version`) REFERENCES `hmac_secret_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
|
FOREIGN KEY (`user_id`,`hmac_secret_key_version`) REFERENCES `hmac_secret_key`(`user_id`,`version`) ON UPDATE no action ON DELETE no action
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE `file` ADD `encrypted_created_at` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `file` ADD `encrypted_last_modified_at` text NOT NULL;
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "928e5669-81cf-486c-9122-8ee64fc9f457",
|
"id": "396a26d6-6f55-4162-a23e-c1117f3a3757",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"client": {
|
"client": {
|
||||||
@@ -470,12 +470,33 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"encrypted_content_hash": {
|
||||||
|
"name": "encrypted_content_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"encrypted_name": {
|
"encrypted_name": {
|
||||||
"name": "encrypted_name",
|
"name": "encrypted_name",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"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": {
|
"indexes": {
|
||||||
@@ -763,10 +784,10 @@
|
|||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"hmac_secret_key_log_action_by_user_id_fk": {
|
"hmac_secret_key_log_action_by_client_id_fk": {
|
||||||
"name": "hmac_secret_key_log_action_by_user_id_fk",
|
"name": "hmac_secret_key_log_action_by_client_id_fk",
|
||||||
"tableFrom": "hmac_secret_key_log",
|
"tableFrom": "hmac_secret_key_log",
|
||||||
"tableTo": "user",
|
"tableTo": "client",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"action_by"
|
"action_by"
|
||||||
],
|
],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,15 +5,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1736704436996,
|
"when": 1737219722656,
|
||||||
"tag": "0000_unknown_stark_industries",
|
"tag": "0000_regular_the_watchers",
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 1,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1736720831242,
|
|
||||||
"tag": "0001_blushing_alice",
|
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"@types/ms": "^0.7.34",
|
"@types/ms": "^0.7.34",
|
||||||
"@types/node-schedule": "^2.1.7",
|
"@types/node-schedule": "^2.1.7",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"axios": "^1.7.9",
|
||||||
"dexie": "^4.0.10",
|
"dexie": "^4.0.10",
|
||||||
"drizzle-kit": "^0.22.8",
|
"drizzle-kit": "^0.22.8",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"mime": "^4.0.6",
|
"mime": "^4.0.6",
|
||||||
|
"p-limit": "^6.2.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.2",
|
"prettier-plugin-svelte": "^3.3.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
"vite": "^5.4.11"
|
"vite": "^5.4.11"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/busboy": "^3.1.1",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
"better-sqlite3": "^11.7.2",
|
"better-sqlite3": "^11.7.2",
|
||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
|
|||||||
98
pnpm-lock.yaml
generated
98
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@fastify/busboy':
|
||||||
|
specifier: ^3.1.1
|
||||||
|
version: 3.1.1
|
||||||
argon2:
|
argon2:
|
||||||
specifier: ^0.41.1
|
specifier: ^0.41.1
|
||||||
version: 0.41.1
|
version: 0.41.1
|
||||||
@@ -60,6 +63,9 @@ importers:
|
|||||||
autoprefixer:
|
autoprefixer:
|
||||||
specifier: ^10.4.20
|
specifier: ^10.4.20
|
||||||
version: 10.4.20(postcss@8.4.49)
|
version: 10.4.20(postcss@8.4.49)
|
||||||
|
axios:
|
||||||
|
specifier: ^1.7.9
|
||||||
|
version: 1.7.9
|
||||||
dexie:
|
dexie:
|
||||||
specifier: ^4.0.10
|
specifier: ^4.0.10
|
||||||
version: 4.0.10
|
version: 4.0.10
|
||||||
@@ -93,6 +99,9 @@ importers:
|
|||||||
mime:
|
mime:
|
||||||
specifier: ^4.0.6
|
specifier: ^4.0.6
|
||||||
version: 4.0.6
|
version: 4.0.6
|
||||||
|
p-limit:
|
||||||
|
specifier: ^6.2.0
|
||||||
|
version: 6.2.0
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.4.2
|
specifier: ^3.4.2
|
||||||
version: 3.4.2
|
version: 3.4.2
|
||||||
@@ -602,6 +611,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==}
|
resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
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':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -969,6 +981,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
|
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
asynckit@0.4.0:
|
||||||
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
autoprefixer@10.4.20:
|
autoprefixer@10.4.20:
|
||||||
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
|
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@@ -976,6 +991,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss: ^8.1.0
|
postcss: ^8.1.0
|
||||||
|
|
||||||
|
axios@1.7.9:
|
||||||
|
resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==}
|
||||||
|
|
||||||
axobject-query@4.1.0:
|
axobject-query@4.1.0:
|
||||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1057,6 +1075,10 @@ packages:
|
|||||||
color-name@1.1.4:
|
color-name@1.1.4:
|
||||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
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:
|
commander@4.1.1:
|
||||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -1111,6 +1133,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0:
|
||||||
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
detect-libc@2.0.3:
|
detect-libc@2.0.3:
|
||||||
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
|
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1406,10 +1432,23 @@ packages:
|
|||||||
flatted@3.3.2:
|
flatted@3.3.2:
|
||||||
resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==}
|
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:
|
foreground-child@3.3.0:
|
||||||
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
|
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
form-data@4.0.1:
|
||||||
|
resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
fraction.js@4.3.7:
|
fraction.js@4.3.7:
|
||||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||||
|
|
||||||
@@ -1613,6 +1652,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
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:
|
mime@4.0.6:
|
||||||
resolution: {integrity: sha512-4rGt7rvQHBbaSOF9POGkk1ocRP16Md1x36Xma8sz8h8/vfCUI2OtEIeCqe4Ofes853x4xDoPiFLIT47J5fI/7A==}
|
resolution: {integrity: sha512-4rGt7rvQHBbaSOF9POGkk1ocRP16Md1x36Xma8sz8h8/vfCUI2OtEIeCqe4Ofes853x4xDoPiFLIT47J5fI/7A==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -1713,6 +1760,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-limit@6.2.0:
|
||||||
|
resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
p-locate@5.0.0:
|
p-locate@5.0.0:
|
||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1907,6 +1958,9 @@ packages:
|
|||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0:
|
||||||
|
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||||
|
|
||||||
pump@3.0.2:
|
pump@3.0.2:
|
||||||
resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==}
|
resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==}
|
||||||
|
|
||||||
@@ -2257,6 +2311,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
yocto-queue@1.1.1:
|
||||||
|
resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==}
|
||||||
|
engines: {node: '>=12.20'}
|
||||||
|
|
||||||
zimmerframe@1.1.2:
|
zimmerframe@1.1.2:
|
||||||
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
|
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
|
||||||
|
|
||||||
@@ -2543,6 +2601,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
|
'@fastify/busboy@3.1.1': {}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.6':
|
'@humanfs/node@0.16.6':
|
||||||
@@ -2911,6 +2971,8 @@ snapshots:
|
|||||||
|
|
||||||
aria-query@5.3.2: {}
|
aria-query@5.3.2: {}
|
||||||
|
|
||||||
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
autoprefixer@10.4.20(postcss@8.4.49):
|
autoprefixer@10.4.20(postcss@8.4.49):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.24.4
|
browserslist: 4.24.4
|
||||||
@@ -2921,6 +2983,14 @@ snapshots:
|
|||||||
postcss: 8.4.49
|
postcss: 8.4.49
|
||||||
postcss-value-parser: 4.2.0
|
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: {}
|
axobject-query@4.1.0: {}
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
@@ -3008,6 +3078,10 @@ snapshots:
|
|||||||
|
|
||||||
color-name@1.1.4: {}
|
color-name@1.1.4: {}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
dependencies:
|
||||||
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
commander@4.1.1: {}
|
commander@4.1.1: {}
|
||||||
|
|
||||||
commondir@1.0.1: {}
|
commondir@1.0.1: {}
|
||||||
@@ -3044,6 +3118,8 @@ snapshots:
|
|||||||
|
|
||||||
deepmerge@4.3.1: {}
|
deepmerge@4.3.1: {}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
detect-libc@2.0.3: {}
|
detect-libc@2.0.3: {}
|
||||||
|
|
||||||
devalue@5.1.1: {}
|
devalue@5.1.1: {}
|
||||||
@@ -3340,11 +3416,19 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.3.2: {}
|
flatted@3.3.2: {}
|
||||||
|
|
||||||
|
follow-redirects@1.15.9: {}
|
||||||
|
|
||||||
foreground-child@3.3.0:
|
foreground-child@3.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
signal-exit: 4.1.0
|
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: {}
|
fraction.js@4.3.7: {}
|
||||||
|
|
||||||
fs-constants@1.0.0: {}
|
fs-constants@1.0.0: {}
|
||||||
@@ -3511,6 +3595,12 @@ snapshots:
|
|||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
dependencies:
|
||||||
|
mime-db: 1.52.0
|
||||||
|
|
||||||
mime@4.0.6: {}
|
mime@4.0.6: {}
|
||||||
|
|
||||||
mimic-response@3.1.0: {}
|
mimic-response@3.1.0: {}
|
||||||
@@ -3595,6 +3685,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yocto-queue: 0.1.0
|
yocto-queue: 0.1.0
|
||||||
|
|
||||||
|
p-limit@6.2.0:
|
||||||
|
dependencies:
|
||||||
|
yocto-queue: 1.1.1
|
||||||
|
|
||||||
p-locate@5.0.0:
|
p-locate@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
@@ -3718,6 +3812,8 @@ snapshots:
|
|||||||
|
|
||||||
prettier@3.4.2: {}
|
prettier@3.4.2: {}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
pump@3.0.2:
|
pump@3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
end-of-stream: 1.4.4
|
end-of-stream: 1.4.4
|
||||||
@@ -4084,6 +4180,8 @@ snapshots:
|
|||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
yocto-queue@1.1.1: {}
|
||||||
|
|
||||||
zimmerframe@1.1.2: {}
|
zimmerframe@1.1.2: {}
|
||||||
|
|
||||||
zod@3.24.1: {}
|
zod@3.24.1: {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { ClientInit } from "@sveltejs/kit";
|
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";
|
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
|
||||||
|
|
||||||
const prepareClientKeyStore = async () => {
|
const prepareClientKeyStore = async () => {
|
||||||
@@ -29,5 +31,13 @@ const prepareHmacSecretStore = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const init: ClientInit = async () => {
|
export const init: ClientInit = async () => {
|
||||||
await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore(), prepareHmacSecretStore()]);
|
await Promise.all([
|
||||||
|
prepareFileCache(),
|
||||||
|
prepareClientKeyStore(),
|
||||||
|
prepareMasterKeyStore(),
|
||||||
|
prepareHmacSecretStore(),
|
||||||
|
prepareOpfs(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
cleanupDanglingInfos(); // Intended
|
||||||
};
|
};
|
||||||
|
|||||||
28
src/lib/indexedDB/cacheIndex.ts
Normal file
28
src/lib/indexedDB/cacheIndex.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Dexie, type EntityTable } from "dexie";
|
||||||
|
|
||||||
|
export interface FileCacheIndex {
|
||||||
|
fileId: number;
|
||||||
|
cachedAt: Date;
|
||||||
|
lastRetrievedAt: Date;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheIndex = new Dexie("cacheIndex") as Dexie & {
|
||||||
|
fileCache: EntityTable<FileCacheIndex, "fileId">;
|
||||||
|
};
|
||||||
|
|
||||||
|
cacheIndex.version(1).stores({
|
||||||
|
fileCache: "fileId",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getFileCacheIndex = async () => {
|
||||||
|
return await cacheIndex.fileCache.toArray();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => {
|
||||||
|
await cacheIndex.fileCache.put(fileCacheIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFileCacheIndex = async (fileId: number) => {
|
||||||
|
await cacheIndex.fileCache.delete(fileId);
|
||||||
|
};
|
||||||
86
src/lib/indexedDB/filesystem.ts
Normal file
86
src/lib/indexedDB/filesystem.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Dexie, type EntityTable } from "dexie";
|
||||||
|
|
||||||
|
export type DirectoryId = "root" | number;
|
||||||
|
|
||||||
|
interface DirectoryInfo {
|
||||||
|
id: number;
|
||||||
|
parentId: DirectoryId;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileInfo {
|
||||||
|
id: number;
|
||||||
|
parentId: DirectoryId;
|
||||||
|
name: string;
|
||||||
|
contentType: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
lastModifiedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesystem = new Dexie("filesystem") as Dexie & {
|
||||||
|
directory: EntityTable<DirectoryInfo, "id">;
|
||||||
|
file: EntityTable<FileInfo, "id">;
|
||||||
|
};
|
||||||
|
|
||||||
|
filesystem.version(1).stores({
|
||||||
|
directory: "id, parentId",
|
||||||
|
file: "id, parentId",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getDirectoryInfos = async (parentId: DirectoryId) => {
|
||||||
|
return await filesystem.directory.where({ parentId }).toArray();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDirectoryInfo = async (id: number) => {
|
||||||
|
return await filesystem.directory.get(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
|
||||||
|
await filesystem.directory.put(directoryInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDirectoryInfo = async (id: number) => {
|
||||||
|
await filesystem.directory.delete(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileInfos = async (parentId: DirectoryId) => {
|
||||||
|
return await filesystem.file.where({ parentId }).toArray();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileInfo = async (id: number) => {
|
||||||
|
return await filesystem.file.get(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeFileInfo = async (fileInfo: FileInfo) => {
|
||||||
|
await filesystem.file.put(fileInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFileInfo = async (id: number) => {
|
||||||
|
await filesystem.file.delete(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cleanupDanglingInfos = async () => {
|
||||||
|
const validDirectoryIds: number[] = [];
|
||||||
|
const validFileIds: number[] = [];
|
||||||
|
const queue: DirectoryId[] = ["root"];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const directoryId = queue.shift();
|
||||||
|
if (!directoryId) break;
|
||||||
|
|
||||||
|
const [subDirectories, files] = await Promise.all([
|
||||||
|
filesystem.directory.where({ parentId: directoryId }).toArray(),
|
||||||
|
filesystem.file.where({ parentId: directoryId }).toArray(),
|
||||||
|
]);
|
||||||
|
subDirectories.forEach(({ id }) => {
|
||||||
|
validDirectoryIds.push(id);
|
||||||
|
queue.push(id);
|
||||||
|
});
|
||||||
|
files.forEach(({ id }) => validFileIds.push(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
filesystem.directory.where("id").noneOf(validDirectoryIds).delete(),
|
||||||
|
filesystem.file.where("id").noneOf(validFileIds).delete(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
3
src/lib/indexedDB/index.ts
Normal file
3
src/lib/indexedDB/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./cacheIndex";
|
||||||
|
export * from "./filesystem";
|
||||||
|
export * from "./keyStore";
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { writable, type Writable } from "svelte/store";
|
|
||||||
import { callGetApi } from "$lib/hooks";
|
|
||||||
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
|
|
||||||
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
|
|
||||||
import {
|
|
||||||
directoryInfoStore,
|
|
||||||
fileInfoStore,
|
|
||||||
type DirectoryInfo,
|
|
||||||
type FileInfo,
|
|
||||||
} from "$lib/stores/file";
|
|
||||||
|
|
||||||
const fetchDirectoryInfo = async (
|
|
||||||
directoryId: "root" | number,
|
|
||||||
masterKey: CryptoKey,
|
|
||||||
infoStore: Writable<DirectoryInfo | null>,
|
|
||||||
) => {
|
|
||||||
const res = await callGetApi(`/api/directory/${directoryId}`);
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch directory information");
|
|
||||||
const { metadata, subDirectories, files }: DirectoryInfoResponse = await res.json();
|
|
||||||
|
|
||||||
let newInfo: DirectoryInfo;
|
|
||||||
if (directoryId === "root") {
|
|
||||||
newInfo = {
|
|
||||||
id: "root",
|
|
||||||
subDirectoryIds: subDirectories,
|
|
||||||
fileIds: files,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
|
|
||||||
newInfo = {
|
|
||||||
id: directoryId,
|
|
||||||
dataKey,
|
|
||||||
dataKeyVersion: new Date(metadata!.dekVersion),
|
|
||||||
name: await decryptString(metadata!.name, metadata!.nameIv, dataKey),
|
|
||||||
subDirectoryIds: subDirectories,
|
|
||||||
fileIds: files,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
infoStore.update(() => newInfo);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDirectoryInfo = (directoryId: "root" | number, masterKey: CryptoKey) => {
|
|
||||||
// TODO: MEK rotation
|
|
||||||
|
|
||||||
let info = directoryInfoStore.get(directoryId);
|
|
||||||
if (!info) {
|
|
||||||
info = writable(null);
|
|
||||||
directoryInfoStore.set(directoryId, info);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchDirectoryInfo(directoryId, masterKey, info);
|
|
||||||
return info;
|
|
||||||
};
|
|
||||||
|
|
||||||
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
|
|
||||||
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchFileInfo = async (
|
|
||||||
fileId: number,
|
|
||||||
masterKey: CryptoKey,
|
|
||||||
infoStore: Writable<FileInfo | null>,
|
|
||||||
) => {
|
|
||||||
const res = await callGetApi(`/api/file/${fileId}`);
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch file information");
|
|
||||||
const metadata: FileInfoResponse = await res.json();
|
|
||||||
|
|
||||||
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
|
|
||||||
const newInfo: FileInfo = {
|
|
||||||
id: fileId,
|
|
||||||
dataKey,
|
|
||||||
dataKeyVersion: new Date(metadata.dekVersion),
|
|
||||||
contentType: metadata.contentType,
|
|
||||||
contentIv: metadata.contentIv,
|
|
||||||
name: await decryptString(metadata.name, metadata.nameIv, dataKey),
|
|
||||||
createdAt:
|
|
||||||
metadata.createdAt && metadata.createdAtIv
|
|
||||||
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
|
|
||||||
: undefined,
|
|
||||||
lastModifiedAt: await decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
|
|
||||||
};
|
|
||||||
|
|
||||||
infoStore.update(() => newInfo);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
|
|
||||||
// TODO: MEK rotation
|
|
||||||
|
|
||||||
let info = fileInfoStore.get(fileId);
|
|
||||||
if (!info) {
|
|
||||||
info = writable(null);
|
|
||||||
fileInfoStore.set(fileId, info);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchFileInfo(fileId, masterKey, info);
|
|
||||||
return info;
|
|
||||||
};
|
|
||||||
50
src/lib/modules/file/cache.ts
Normal file
50
src/lib/modules/file/cache.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
|
||||||
|
storeFileCacheIndex,
|
||||||
|
deleteFileCacheIndex,
|
||||||
|
type FileCacheIndex,
|
||||||
|
} from "$lib/indexedDB";
|
||||||
|
import { readFile, writeFile, deleteFile } from "$lib/modules/opfs";
|
||||||
|
|
||||||
|
const fileCacheIndex = new Map<number, FileCacheIndex>();
|
||||||
|
|
||||||
|
export const prepareFileCache = async () => {
|
||||||
|
for (const cache of await getFileCacheIndexFromIndexedDB()) {
|
||||||
|
fileCacheIndex.set(cache.fileId, cache);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileCacheIndex = () => {
|
||||||
|
return Array.from(fileCacheIndex.values());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileCache = async (fileId: number) => {
|
||||||
|
const cacheIndex = fileCacheIndex.get(fileId);
|
||||||
|
if (!cacheIndex) return null;
|
||||||
|
|
||||||
|
cacheIndex.lastRetrievedAt = new Date();
|
||||||
|
storeFileCacheIndex(cacheIndex); // Intended
|
||||||
|
return await readFile(`/cache/${fileId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeFileCache = async (fileId: number, fileBuffer: ArrayBuffer) => {
|
||||||
|
const now = new Date();
|
||||||
|
await writeFile(`/cache/${fileId}`, fileBuffer);
|
||||||
|
|
||||||
|
const cacheIndex: FileCacheIndex = {
|
||||||
|
fileId,
|
||||||
|
cachedAt: now,
|
||||||
|
lastRetrievedAt: now,
|
||||||
|
size: fileBuffer.byteLength,
|
||||||
|
};
|
||||||
|
fileCacheIndex.set(fileId, cacheIndex);
|
||||||
|
await storeFileCacheIndex(cacheIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFileCache = async (fileId: number) => {
|
||||||
|
if (!fileCacheIndex.has(fileId)) return;
|
||||||
|
|
||||||
|
fileCacheIndex.delete(fileId);
|
||||||
|
await deleteFile(`/cache/${fileId}`);
|
||||||
|
await deleteFileCacheIndex(fileId);
|
||||||
|
};
|
||||||
84
src/lib/modules/file/download.ts
Normal file
84
src/lib/modules/file/download.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { limitFunction } from "p-limit";
|
||||||
|
import { writable, type Writable } from "svelte/store";
|
||||||
|
import { decryptData } from "$lib/modules/crypto";
|
||||||
|
import { fileDownloadStatusStore, type FileDownloadStatus } from "$lib/stores";
|
||||||
|
|
||||||
|
const requestFileDownload = limitFunction(
|
||||||
|
async (status: Writable<FileDownloadStatus>, id: number) => {
|
||||||
|
status.update((value) => {
|
||||||
|
value.status = "downloading";
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await axios.get(`/api/file/${id}/download`, {
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
onDownloadProgress: ({ progress, rate, estimated }) => {
|
||||||
|
status.update((value) => {
|
||||||
|
value.progress = progress;
|
||||||
|
value.rate = rate;
|
||||||
|
value.estimated = estimated;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fileEncrypted: ArrayBuffer = res.data;
|
||||||
|
|
||||||
|
status.update((value) => {
|
||||||
|
value.status = "decryption-pending";
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
return fileEncrypted;
|
||||||
|
},
|
||||||
|
{ concurrency: 1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const decryptFile = limitFunction(
|
||||||
|
async (
|
||||||
|
status: Writable<FileDownloadStatus>,
|
||||||
|
fileEncrypted: ArrayBuffer,
|
||||||
|
fileEncryptedIv: string,
|
||||||
|
dataKey: CryptoKey,
|
||||||
|
) => {
|
||||||
|
status.update((value) => {
|
||||||
|
value.status = "decrypting";
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
|
||||||
|
|
||||||
|
status.update((value) => {
|
||||||
|
value.status = "decrypted";
|
||||||
|
value.result = fileBuffer;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
return fileBuffer;
|
||||||
|
},
|
||||||
|
{ concurrency: 4 },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
|
||||||
|
const status = writable<FileDownloadStatus>({
|
||||||
|
id,
|
||||||
|
status: "download-pending",
|
||||||
|
});
|
||||||
|
fileDownloadStatusStore.update((value) => {
|
||||||
|
value.push(status);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await decryptFile(
|
||||||
|
status,
|
||||||
|
await requestFileDownload(status, id),
|
||||||
|
fileEncryptedIv,
|
||||||
|
dataKey,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
status.update((value) => {
|
||||||
|
value.status = "error";
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
3
src/lib/modules/file/index.ts
Normal file
3
src/lib/modules/file/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./cache";
|
||||||
|
export * from "./download";
|
||||||
|
export * from "./upload";
|
||||||
231
src/lib/modules/file/upload.ts
Normal file
231
src/lib/modules/file/upload.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import ExifReader from "exifreader";
|
||||||
|
import { limitFunction } from "p-limit";
|
||||||
|
import { writable, type Writable } from "svelte/store";
|
||||||
|
import {
|
||||||
|
encodeToBase64,
|
||||||
|
generateDataKey,
|
||||||
|
wrapDataKey,
|
||||||
|
encryptData,
|
||||||
|
encryptString,
|
||||||
|
digestMessage,
|
||||||
|
signMessageHmac,
|
||||||
|
} from "$lib/modules/crypto";
|
||||||
|
import type {
|
||||||
|
DuplicateFileScanRequest,
|
||||||
|
DuplicateFileScanResponse,
|
||||||
|
FileUploadRequest,
|
||||||
|
} from "$lib/server/schemas";
|
||||||
|
import {
|
||||||
|
fileUploadStatusStore,
|
||||||
|
type MasterKey,
|
||||||
|
type HmacSecret,
|
||||||
|
type FileUploadStatus,
|
||||||
|
} from "$lib/stores";
|
||||||
|
|
||||||
|
const requestDuplicateFileScan = limitFunction(
|
||||||
|
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
|
||||||
|
const fileBuffer = await file.arrayBuffer();
|
||||||
|
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
|
||||||
|
|
||||||
|
const res = await axios.post("/api/file/scanDuplicates", {
|
||||||
|
hskVersion: hmacSecret.version,
|
||||||
|
contentHmac: fileSigned,
|
||||||
|
} satisfies DuplicateFileScanRequest);
|
||||||
|
const { files }: DuplicateFileScanResponse = res.data;
|
||||||
|
|
||||||
|
if (files.length === 0 || (await onDuplicate())) {
|
||||||
|
return { fileBuffer, fileSigned };
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ concurrency: 1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const getFileType = (file: File) => {
|
||||||
|
if (file.type) return file.type;
|
||||||
|
if (file.name.endsWith(".heic")) return "image/heic";
|
||||||
|
throw new Error("Unknown file type");
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
|
||||||
|
const exif = ExifReader.load(fileBuffer);
|
||||||
|
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
|
||||||
|
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
|
||||||
|
if (!dateTimeOriginal) return undefined;
|
||||||
|
|
||||||
|
const [date, time] = dateTimeOriginal.split(" ");
|
||||||
|
if (!date || !time) return undefined;
|
||||||
|
|
||||||
|
const [year, month, day] = date.split(":").map(Number);
|
||||||
|
const [hour, minute, second] = time.split(":").map(Number);
|
||||||
|
if (!year || !month || !day || !hour || !minute || !second) return undefined;
|
||||||
|
|
||||||
|
if (!offsetTimeOriginal) {
|
||||||
|
// No timezone information.. Assume local timezone
|
||||||
|
return new Date(year, month - 1, day, hour, minute, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
|
||||||
|
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
|
||||||
|
|
||||||
|
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||||
|
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
|
||||||
|
return new Date(utcDate - offsetMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const encryptFile = limitFunction(
|
||||||
|
async (
|
||||||
|
status: Writable<FileUploadStatus>,
|
||||||
|
file: File,
|
||||||
|
fileBuffer: ArrayBuffer,
|
||||||
|
masterKey: MasterKey,
|
||||||
|
) => {
|
||||||
|
status.update((value) => {
|
||||||
|
value.status = "encrypting";
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileType = getFileType(file);
|
||||||
|
|
||||||
|
let createdAt;
|
||||||
|
if (fileType.startsWith("image/")) {
|
||||||
|
createdAt = extractExifDateTime(fileBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||||
|
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
|
||||||
|
|
||||||
|
const fileEncrypted = await encryptData(fileBuffer, dataKey);
|
||||||
|
const fileEncryptedHash = encodeToBase64(await digestMessage(fileEncrypted.ciphertext));
|
||||||
|
|
||||||
|
const nameEncrypted = await encryptString(file.name, dataKey);
|
||||||
|
const createdAtEncrypted =
|
||||||
|
createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
|
||||||
|
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
|
||||||
|
|
||||||
|
status.update((value) => {
|
||||||
|
value.status = "upload-pending";
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataKeyWrapped,
|
||||||
|
dataKeyVersion,
|
||||||
|
fileType,
|
||||||
|
fileEncrypted,
|
||||||
|
fileEncryptedHash,
|
||||||
|
nameEncrypted,
|
||||||
|
createdAtEncrypted,
|
||||||
|
lastModifiedAtEncrypted,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ concurrency: 4 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestFileUpload = limitFunction(
|
||||||
|
async (status: Writable<FileUploadStatus>, form: FormData) => {
|
||||||
|
status.update((value) => {
|
||||||
|
value.status = "uploading";
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
await axios.post("/api/file/upload", form, {
|
||||||
|
onUploadProgress: ({ progress, rate, estimated }) => {
|
||||||
|
status.update((value) => {
|
||||||
|
value.progress = progress;
|
||||||
|
value.rate = rate;
|
||||||
|
value.estimated = estimated;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
status.update((value) => {
|
||||||
|
value.status = "uploaded";
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ concurrency: 1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const uploadFile = async (
|
||||||
|
file: File,
|
||||||
|
parentId: "root" | number,
|
||||||
|
hmacSecret: HmacSecret,
|
||||||
|
masterKey: MasterKey,
|
||||||
|
onDuplicate: () => Promise<boolean>,
|
||||||
|
) => {
|
||||||
|
const status = writable<FileUploadStatus>({
|
||||||
|
name: file.name,
|
||||||
|
parentId,
|
||||||
|
status: "encryption-pending",
|
||||||
|
});
|
||||||
|
fileUploadStatusStore.update((value) => {
|
||||||
|
value.push(status);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
|
||||||
|
file,
|
||||||
|
hmacSecret,
|
||||||
|
onDuplicate,
|
||||||
|
);
|
||||||
|
if (!fileBuffer || !fileSigned) {
|
||||||
|
status.update((value) => {
|
||||||
|
value.status = "canceled";
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
fileUploadStatusStore.update((value) => {
|
||||||
|
value = value.filter((v) => v !== status);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
dataKeyWrapped,
|
||||||
|
dataKeyVersion,
|
||||||
|
fileType,
|
||||||
|
fileEncrypted,
|
||||||
|
fileEncryptedHash,
|
||||||
|
nameEncrypted,
|
||||||
|
createdAtEncrypted,
|
||||||
|
lastModifiedAtEncrypted,
|
||||||
|
} = await encryptFile(status, file, fileBuffer, masterKey);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set(
|
||||||
|
"metadata",
|
||||||
|
JSON.stringify({
|
||||||
|
parent: parentId,
|
||||||
|
mekVersion: masterKey.version,
|
||||||
|
dek: dataKeyWrapped,
|
||||||
|
dekVersion: dataKeyVersion.toISOString(),
|
||||||
|
hskVersion: hmacSecret.version,
|
||||||
|
contentHmac: fileSigned,
|
||||||
|
contentType: fileType,
|
||||||
|
contentIv: fileEncrypted.iv,
|
||||||
|
name: nameEncrypted.ciphertext,
|
||||||
|
nameIv: nameEncrypted.iv,
|
||||||
|
createdAt: createdAtEncrypted?.ciphertext,
|
||||||
|
createdAtIv: createdAtEncrypted?.iv,
|
||||||
|
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
|
||||||
|
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
|
||||||
|
} as FileUploadRequest),
|
||||||
|
);
|
||||||
|
form.set("content", new Blob([fileEncrypted.ciphertext]));
|
||||||
|
form.set("checksum", fileEncryptedHash);
|
||||||
|
|
||||||
|
await requestFileUpload(status, form);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
status.update((value) => {
|
||||||
|
value.status = "error";
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
208
src/lib/modules/filesystem.ts
Normal file
208
src/lib/modules/filesystem.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { get, writable, type Writable } from "svelte/store";
|
||||||
|
import { callGetApi } from "$lib/hooks";
|
||||||
|
import {
|
||||||
|
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
|
||||||
|
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
|
||||||
|
storeDirectoryInfo,
|
||||||
|
deleteDirectoryInfo,
|
||||||
|
getFileInfos as getFileInfosFromIndexedDB,
|
||||||
|
getFileInfo as getFileInfoFromIndexedDB,
|
||||||
|
storeFileInfo,
|
||||||
|
deleteFileInfo,
|
||||||
|
type DirectoryId,
|
||||||
|
} from "$lib/indexedDB";
|
||||||
|
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
|
||||||
|
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
|
||||||
|
|
||||||
|
export type DirectoryInfo =
|
||||||
|
| {
|
||||||
|
id: "root";
|
||||||
|
dataKey?: undefined;
|
||||||
|
dataKeyVersion?: undefined;
|
||||||
|
name?: undefined;
|
||||||
|
subDirectoryIds: number[];
|
||||||
|
fileIds: number[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: number;
|
||||||
|
dataKey?: CryptoKey;
|
||||||
|
dataKeyVersion?: Date;
|
||||||
|
name: string;
|
||||||
|
subDirectoryIds: number[];
|
||||||
|
fileIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
id: number;
|
||||||
|
dataKey?: CryptoKey;
|
||||||
|
dataKeyVersion?: Date;
|
||||||
|
contentType: string;
|
||||||
|
contentIv?: string;
|
||||||
|
name: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
lastModifiedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
|
||||||
|
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
|
||||||
|
|
||||||
|
const fetchDirectoryInfoFromIndexedDB = async (
|
||||||
|
id: DirectoryId,
|
||||||
|
info: Writable<DirectoryInfo | null>,
|
||||||
|
) => {
|
||||||
|
if (get(info)) return;
|
||||||
|
|
||||||
|
const [directory, subDirectories, files] = await Promise.all([
|
||||||
|
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
|
||||||
|
getDirectoryInfosFromIndexedDB(id),
|
||||||
|
getFileInfosFromIndexedDB(id),
|
||||||
|
]);
|
||||||
|
const subDirectoryIds = subDirectories.map(({ id }) => id);
|
||||||
|
const fileIds = files.map(({ id }) => id);
|
||||||
|
|
||||||
|
if (id === "root") {
|
||||||
|
info.set({ id, subDirectoryIds, fileIds });
|
||||||
|
} else {
|
||||||
|
if (!directory) return;
|
||||||
|
info.set({ id, name: directory.name, subDirectoryIds, fileIds });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDirectoryInfoFromServer = async (
|
||||||
|
id: DirectoryId,
|
||||||
|
info: Writable<DirectoryInfo | null>,
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
) => {
|
||||||
|
const res = await callGetApi(`/api/directory/${id}`);
|
||||||
|
if (res.status === 404) {
|
||||||
|
info.set(null);
|
||||||
|
await deleteDirectoryInfo(id as number);
|
||||||
|
return;
|
||||||
|
} else if (!res.ok) {
|
||||||
|
throw new Error("Failed to fetch directory information");
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
metadata,
|
||||||
|
subDirectories: subDirectoryIds,
|
||||||
|
files: fileIds,
|
||||||
|
}: DirectoryInfoResponse = await res.json();
|
||||||
|
|
||||||
|
if (id === "root") {
|
||||||
|
info.set({ id, subDirectoryIds, fileIds });
|
||||||
|
} else {
|
||||||
|
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
|
||||||
|
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
|
||||||
|
|
||||||
|
info.set({
|
||||||
|
id,
|
||||||
|
dataKey,
|
||||||
|
dataKeyVersion: new Date(metadata!.dekVersion),
|
||||||
|
name,
|
||||||
|
subDirectoryIds,
|
||||||
|
fileIds,
|
||||||
|
});
|
||||||
|
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDirectoryInfo = async (
|
||||||
|
id: DirectoryId,
|
||||||
|
info: Writable<DirectoryInfo | null>,
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
) => {
|
||||||
|
await fetchDirectoryInfoFromIndexedDB(id, info);
|
||||||
|
await fetchDirectoryInfoFromServer(id, info, masterKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
|
||||||
|
// TODO: MEK rotation
|
||||||
|
|
||||||
|
let info = directoryInfoStore.get(id);
|
||||||
|
if (!info) {
|
||||||
|
info = writable(null);
|
||||||
|
directoryInfoStore.set(id, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDirectoryInfo(id, info, masterKey);
|
||||||
|
return info;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFileInfoFromIndexedDB = async (id: number, info: Writable<FileInfo | null>) => {
|
||||||
|
if (get(info)) return;
|
||||||
|
|
||||||
|
const file = await getFileInfoFromIndexedDB(id);
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
info.set(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
|
||||||
|
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFileInfoFromServer = async (
|
||||||
|
id: number,
|
||||||
|
info: Writable<FileInfo | null>,
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
) => {
|
||||||
|
const res = await callGetApi(`/api/file/${id}`);
|
||||||
|
if (res.status === 404) {
|
||||||
|
info.set(null);
|
||||||
|
await deleteFileInfo(id);
|
||||||
|
return;
|
||||||
|
} else if (!res.ok) {
|
||||||
|
throw new Error("Failed to fetch file information");
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: FileInfoResponse = await res.json();
|
||||||
|
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
|
||||||
|
|
||||||
|
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
|
||||||
|
const createdAt =
|
||||||
|
metadata.createdAt && metadata.createdAtIv
|
||||||
|
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
|
||||||
|
: undefined;
|
||||||
|
const lastModifiedAt = await decryptDate(
|
||||||
|
metadata.lastModifiedAt,
|
||||||
|
metadata.lastModifiedAtIv,
|
||||||
|
dataKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
info.set({
|
||||||
|
id,
|
||||||
|
dataKey,
|
||||||
|
dataKeyVersion: new Date(metadata.dekVersion),
|
||||||
|
contentType: metadata.contentType,
|
||||||
|
contentIv: metadata.contentIv,
|
||||||
|
name,
|
||||||
|
createdAt,
|
||||||
|
lastModifiedAt,
|
||||||
|
});
|
||||||
|
await storeFileInfo({
|
||||||
|
id,
|
||||||
|
parentId: metadata.parent,
|
||||||
|
name,
|
||||||
|
contentType: metadata.contentType,
|
||||||
|
createdAt,
|
||||||
|
lastModifiedAt,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFileInfo = async (id: number, info: Writable<FileInfo | null>, masterKey: CryptoKey) => {
|
||||||
|
await fetchFileInfoFromIndexedDB(id, info);
|
||||||
|
await fetchFileInfoFromServer(id, info, masterKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
|
||||||
|
// TODO: MEK rotation
|
||||||
|
|
||||||
|
let info = fileInfoStore.get(fileId);
|
||||||
|
if (!info) {
|
||||||
|
info = writable(null);
|
||||||
|
fileInfoStore.set(fileId, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchFileInfo(fileId, info, masterKey);
|
||||||
|
return info;
|
||||||
|
};
|
||||||
61
src/lib/modules/opfs.ts
Normal file
61
src/lib/modules/opfs.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
let rootHandle: FileSystemDirectoryHandle | null = null;
|
||||||
|
|
||||||
|
export const prepareOpfs = async () => {
|
||||||
|
rootHandle = await navigator.storage.getDirectory();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileHandle = async (path: string, create = true) => {
|
||||||
|
if (!rootHandle) {
|
||||||
|
throw new Error("OPFS not prepared");
|
||||||
|
} else if (path[0] !== "/") {
|
||||||
|
throw new Error("Path must be absolute");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = path.split("/");
|
||||||
|
if (parts.length <= 1) {
|
||||||
|
throw new Error("Invalid path");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let directoryHandle = rootHandle;
|
||||||
|
for (const part of parts.slice(0, -1)) {
|
||||||
|
if (!part) continue;
|
||||||
|
directoryHandle = await directoryHandle.getDirectoryHandle(part, { create });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = parts[parts.length - 1]!;
|
||||||
|
const fileHandle = await directoryHandle.getFileHandle(filename, { create });
|
||||||
|
return { parentHandle: directoryHandle, filename, fileHandle };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DOMException && e.name === "NotFoundError") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readFile = async (path: string) => {
|
||||||
|
const { fileHandle } = await getFileHandle(path, false);
|
||||||
|
if (!fileHandle) return null;
|
||||||
|
|
||||||
|
const file = await fileHandle.getFile();
|
||||||
|
return await file.arrayBuffer();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeFile = async (path: string, data: ArrayBuffer) => {
|
||||||
|
const { fileHandle } = await getFileHandle(path);
|
||||||
|
const writable = await fileHandle!.createWritable();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writable.write(data);
|
||||||
|
} finally {
|
||||||
|
await writable.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFile = async (path: string) => {
|
||||||
|
const { parentHandle, filename } = await getFileHandle(path, false);
|
||||||
|
if (!parentHandle) return;
|
||||||
|
|
||||||
|
await parentHandle.removeEntry(filename);
|
||||||
|
};
|
||||||
29
src/lib/modules/util.ts
Normal file
29
src/lib/modules/util.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const pad2 = (num: number) => num.toString().padStart(2, "0");
|
||||||
|
|
||||||
|
export const formatDate = (date: Date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth() + 1;
|
||||||
|
const day = date.getDate();
|
||||||
|
return `${year}. ${month}. ${day}.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDateTime = (date: Date) => {
|
||||||
|
const dateFormatted = formatDate(date);
|
||||||
|
const hours = date.getHours();
|
||||||
|
const minutes = date.getMinutes();
|
||||||
|
return `${dateFormatted} ${pad2(hours)}:${pad2(minutes)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatFileSize = (size: number) => {
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`;
|
||||||
|
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MiB`;
|
||||||
|
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatNetworkSpeed = (speed: number) => {
|
||||||
|
if (speed < 1000) return `${speed} bps`;
|
||||||
|
if (speed < 1000 * 1000) return `${(speed / 1000).toFixed(1)} kbps`;
|
||||||
|
if (speed < 1000 * 1000 * 1000) return `${(speed / 1000 / 1000).toFixed(1)} Mbps`;
|
||||||
|
return `${(speed / 1000 / 1000 / 1000).toFixed(1)} Gbps`;
|
||||||
|
};
|
||||||
@@ -27,6 +27,7 @@ export interface NewFileParams {
|
|||||||
contentHmac: string | null;
|
contentHmac: string | null;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
encContentIv: string;
|
encContentIv: string;
|
||||||
|
encContentHash: string;
|
||||||
encName: string;
|
encName: string;
|
||||||
encNameIv: string;
|
encNameIv: string;
|
||||||
encCreatedAt: string | null;
|
encCreatedAt: string | null;
|
||||||
@@ -130,14 +131,15 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
|
|||||||
return await db.transaction(
|
return await db.transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
const unregisterFiles = async (parentId: number) => {
|
const unregisterFiles = async (parentId: number) => {
|
||||||
const files = await tx
|
return await tx
|
||||||
.delete(file)
|
.delete(file)
|
||||||
.where(and(eq(file.userId, userId), eq(file.parentId, parentId)))
|
.where(and(eq(file.userId, userId), eq(file.parentId, parentId)))
|
||||||
.returning({ path: file.path });
|
.returning({ id: file.id, path: file.path });
|
||||||
return files.map(({ path }) => path);
|
|
||||||
};
|
};
|
||||||
const unregisterDirectoryRecursively = async (directoryId: number): Promise<string[]> => {
|
const unregisterDirectoryRecursively = async (
|
||||||
const filePaths = await unregisterFiles(directoryId);
|
directoryId: number,
|
||||||
|
): Promise<{ id: number; path: string }[]> => {
|
||||||
|
const files = await unregisterFiles(directoryId);
|
||||||
const subDirectories = await tx
|
const subDirectories = await tx
|
||||||
.select({ id: directory.id })
|
.select({ id: directory.id })
|
||||||
.from(directory)
|
.from(directory)
|
||||||
@@ -150,7 +152,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
|
|||||||
if (deleteRes.changes === 0) {
|
if (deleteRes.changes === 0) {
|
||||||
throw new IntegrityError("Directory not found");
|
throw new IntegrityError("Directory not found");
|
||||||
}
|
}
|
||||||
return filePaths.concat(...subDirectoryFilePaths);
|
return files.concat(...subDirectoryFilePaths);
|
||||||
};
|
};
|
||||||
return await unregisterDirectoryRecursively(directoryId);
|
return await unregisterDirectoryRecursively(directoryId);
|
||||||
},
|
},
|
||||||
@@ -198,11 +200,12 @@ export const registerFile = async (params: NewFileParams) => {
|
|||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
mekVersion: params.mekVersion,
|
mekVersion: params.mekVersion,
|
||||||
hskVersion: params.hskVersion,
|
hskVersion: params.hskVersion,
|
||||||
contentHmac: params.contentHmac,
|
|
||||||
contentType: params.contentType,
|
|
||||||
encDek: params.encDek,
|
encDek: params.encDek,
|
||||||
dekVersion: params.dekVersion,
|
dekVersion: params.dekVersion,
|
||||||
|
contentHmac: params.contentHmac,
|
||||||
|
contentType: params.contentType,
|
||||||
encContentIv: params.encContentIv,
|
encContentIv: params.encContentIv,
|
||||||
|
encContentHash: params.encContentHash,
|
||||||
encName: { ciphertext: params.encName, iv: params.encNameIv },
|
encName: { ciphertext: params.encName, iv: params.encNameIv },
|
||||||
encCreatedAt:
|
encCreatedAt:
|
||||||
params.encCreatedAt && params.encCreatedAtIv
|
params.encCreatedAt && params.encCreatedAtIv
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export const file = sqliteTable(
|
|||||||
contentHmac: text("content_hmac"), // Base64
|
contentHmac: text("content_hmac"), // Base64
|
||||||
contentType: text("content_type").notNull(),
|
contentType: text("content_type").notNull(),
|
||||||
encContentIv: text("encrypted_content_iv").notNull(), // Base64
|
encContentIv: text("encrypted_content_iv").notNull(), // Base64
|
||||||
|
encContentHash: text("encrypted_content_hash").notNull(), // Base64
|
||||||
encName: ciphertext("encrypted_name").notNull(),
|
encName: ciphertext("encrypted_name").notNull(),
|
||||||
encCreatedAt: ciphertext("encrypted_created_at"),
|
encCreatedAt: ciphertext("encrypted_created_at"),
|
||||||
encLastModifiedAt: ciphertext("encrypted_last_modified_at").notNull(),
|
encLastModifiedAt: ciphertext("encrypted_last_modified_at").notNull(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { sqliteTable, text, integer, primaryKey, foreignKey } from "drizzle-orm/sqlite-core";
|
import { sqliteTable, text, integer, primaryKey, foreignKey } from "drizzle-orm/sqlite-core";
|
||||||
|
import { client } from "./client";
|
||||||
import { mek } from "./mek";
|
import { mek } from "./mek";
|
||||||
import { user } from "./user";
|
import { user } from "./user";
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ export const hskLog = sqliteTable(
|
|||||||
hskVersion: integer("hmac_secret_key_version").notNull(),
|
hskVersion: integer("hmac_secret_key_version").notNull(),
|
||||||
timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(),
|
timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(),
|
||||||
action: text("action", { enum: ["create"] }).notNull(),
|
action: text("action", { enum: ["create"] }).notNull(),
|
||||||
actionBy: integer("action_by").references(() => user.id),
|
actionBy: integer("action_by").references(() => client.id),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
ref: foreignKey({
|
ref: foreignKey({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
export const directoryInfoResponse = z.object({
|
export const directoryInfoResponse = z.object({
|
||||||
metadata: z
|
metadata: z
|
||||||
.object({
|
.object({
|
||||||
|
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||||
mekVersion: z.number().int().positive(),
|
mekVersion: z.number().int().positive(),
|
||||||
dek: z.string().base64().nonempty(),
|
dek: z.string().base64().nonempty(),
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
@@ -15,6 +16,11 @@ export const directoryInfoResponse = z.object({
|
|||||||
});
|
});
|
||||||
export type DirectoryInfoResponse = z.infer<typeof directoryInfoResponse>;
|
export type DirectoryInfoResponse = z.infer<typeof directoryInfoResponse>;
|
||||||
|
|
||||||
|
export const directoryDeleteResponse = z.object({
|
||||||
|
deletedFiles: z.number().int().positive().array(),
|
||||||
|
});
|
||||||
|
export type DirectoryDeleteResponse = z.infer<typeof directoryDeleteResponse>;
|
||||||
|
|
||||||
export const directoryRenameRequest = z.object({
|
export const directoryRenameRequest = z.object({
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
name: z.string().base64().nonempty(),
|
name: z.string().base64().nonempty(),
|
||||||
@@ -23,7 +29,7 @@ export const directoryRenameRequest = z.object({
|
|||||||
export type DirectoryRenameRequest = z.infer<typeof directoryRenameRequest>;
|
export type DirectoryRenameRequest = z.infer<typeof directoryRenameRequest>;
|
||||||
|
|
||||||
export const directoryCreateRequest = z.object({
|
export const directoryCreateRequest = z.object({
|
||||||
parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
|
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||||
mekVersion: z.number().int().positive(),
|
mekVersion: z.number().int().positive(),
|
||||||
dek: z.string().base64().nonempty(),
|
dek: z.string().base64().nonempty(),
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import mime from "mime";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const fileInfoResponse = z.object({
|
export const fileInfoResponse = z.object({
|
||||||
|
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||||
mekVersion: z.number().int().positive(),
|
mekVersion: z.number().int().positive(),
|
||||||
dek: z.string().base64().nonempty(),
|
dek: z.string().base64().nonempty(),
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
@@ -38,7 +39,7 @@ export const duplicateFileScanResponse = z.object({
|
|||||||
export type DuplicateFileScanResponse = z.infer<typeof duplicateFileScanResponse>;
|
export type DuplicateFileScanResponse = z.infer<typeof duplicateFileScanResponse>;
|
||||||
|
|
||||||
export const fileUploadRequest = z.object({
|
export const fileUploadRequest = z.object({
|
||||||
parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
|
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||||
mekVersion: z.number().int().positive(),
|
mekVersion: z.number().int().positive(),
|
||||||
dek: z.string().base64().nonempty(),
|
dek: z.string().base64().nonempty(),
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
|
|||||||
|
|
||||||
const directories = await getAllDirectoriesByParent(userId, directoryId);
|
const directories = await getAllDirectoriesByParent(userId, directoryId);
|
||||||
const files = await getAllFilesByParent(userId, directoryId);
|
const files = await getAllFilesByParent(userId, directoryId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metadata: directory && {
|
metadata: directory && {
|
||||||
|
parentId: directory.parentId ?? ("root" as const),
|
||||||
mekVersion: directory.mekVersion,
|
mekVersion: directory.mekVersion,
|
||||||
encDek: directory.encDek,
|
encDek: directory.encDek,
|
||||||
dekVersion: directory.dekVersion,
|
dekVersion: directory.dekVersion,
|
||||||
@@ -34,8 +34,13 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
|
|||||||
|
|
||||||
export const deleteDirectory = async (userId: number, directoryId: number) => {
|
export const deleteDirectory = async (userId: number, directoryId: number) => {
|
||||||
try {
|
try {
|
||||||
const filePaths = await unregisterDirectory(userId, directoryId);
|
const files = await unregisterDirectory(userId, directoryId);
|
||||||
filePaths.map((path) => unlink(path)); // Intended
|
return {
|
||||||
|
files: files.map(({ id, path }) => {
|
||||||
|
unlink(path); // Intended
|
||||||
|
return id;
|
||||||
|
}),
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof IntegrityError && e.message === "Directory not found") {
|
if (e instanceof IntegrityError && e.message === "Directory not found") {
|
||||||
error(404, "Invalid directory id");
|
error(404, "Invalid directory id");
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import { createReadStream, createWriteStream } from "fs";
|
import { createReadStream, createWriteStream } from "fs";
|
||||||
import { mkdir, stat, unlink } from "fs/promises";
|
import { mkdir, stat, unlink } from "fs/promises";
|
||||||
import { dirname } from "path";
|
import { dirname } from "path";
|
||||||
import { Readable, Writable } from "stream";
|
import { Readable } from "stream";
|
||||||
|
import { pipeline } from "stream/promises";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { IntegrityError } from "$lib/server/db/error";
|
import { IntegrityError } from "$lib/server/db/error";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +24,7 @@ export const getFileInformation = async (userId: number, fileId: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
parentId: file.parentId ?? ("root" as const),
|
||||||
mekVersion: file.mekVersion,
|
mekVersion: file.mekVersion,
|
||||||
encDek: file.encDek,
|
encDek: file.encDek,
|
||||||
dekVersion: file.dekVersion,
|
dekVersion: file.dekVersion,
|
||||||
@@ -93,12 +96,13 @@ const safeUnlink = async (path: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const uploadFile = async (
|
export const uploadFile = async (
|
||||||
params: Omit<NewFileParams, "path">,
|
params: Omit<NewFileParams, "path" | "encContentHash">,
|
||||||
encContentStream: ReadableStream<Uint8Array>,
|
encContentStream: Readable,
|
||||||
|
encContentHash: Promise<string>,
|
||||||
) => {
|
) => {
|
||||||
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);
|
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
|
||||||
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
|
if (params.dekVersion <= oneDayAgo || params.dekVersion >= oneMinuteLater) {
|
||||||
error(400, "Invalid DEK version");
|
error(400, "Invalid DEK version");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,20 +110,39 @@ export const uploadFile = async (
|
|||||||
await mkdir(dirname(path), { recursive: true });
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await encContentStream.pipeTo(
|
const hashStream = createHash("sha256");
|
||||||
Writable.toWeb(createWriteStream(path, { flags: "wx", mode: 0o600 })),
|
const [_, hash] = await Promise.all([
|
||||||
);
|
pipeline(
|
||||||
|
encContentStream,
|
||||||
|
async function* (source) {
|
||||||
|
for await (const chunk of source) {
|
||||||
|
hashStream.update(chunk);
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createWriteStream(path, { flags: "wx", mode: 0o600 }),
|
||||||
|
),
|
||||||
|
encContentHash,
|
||||||
|
]);
|
||||||
|
if (hashStream.digest("base64") != hash) {
|
||||||
|
throw new Error("Invalid checksum");
|
||||||
|
}
|
||||||
|
|
||||||
await registerFile({
|
await registerFile({
|
||||||
...params,
|
...params,
|
||||||
path,
|
path,
|
||||||
|
encContentHash: hash,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await safeUnlink(path);
|
await safeUnlink(path);
|
||||||
|
|
||||||
if (e instanceof IntegrityError) {
|
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
|
||||||
if (e.message === "Inactive MEK version") {
|
|
||||||
error(400, "Invalid MEK version");
|
error(400, "Invalid MEK version");
|
||||||
}
|
} else if (
|
||||||
|
e instanceof Error &&
|
||||||
|
(e.message === "Invalid request body" || e.message === "Invalid checksum")
|
||||||
|
) {
|
||||||
|
error(400, "Invalid request body");
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,49 @@
|
|||||||
import type { Writable } from "svelte/store";
|
import { writable, type Writable } from "svelte/store";
|
||||||
|
|
||||||
export type DirectoryInfo =
|
export interface FileUploadStatus {
|
||||||
| {
|
|
||||||
id: "root";
|
|
||||||
dataKey?: undefined;
|
|
||||||
dataKeyVersion?: undefined;
|
|
||||||
name?: undefined;
|
|
||||||
subDirectoryIds: number[];
|
|
||||||
fileIds: number[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
id: number;
|
|
||||||
dataKey: CryptoKey;
|
|
||||||
dataKeyVersion: Date;
|
|
||||||
name: string;
|
name: string;
|
||||||
subDirectoryIds: number[];
|
parentId: "root" | number;
|
||||||
fileIds: number[];
|
status:
|
||||||
|
| "encryption-pending"
|
||||||
|
| "encrypting"
|
||||||
|
| "upload-pending"
|
||||||
|
| "uploading"
|
||||||
|
| "uploaded"
|
||||||
|
| "canceled"
|
||||||
|
| "error";
|
||||||
|
progress?: number;
|
||||||
|
rate?: number;
|
||||||
|
estimated?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileDownloadStatus {
|
||||||
|
id: number;
|
||||||
|
status:
|
||||||
|
| "download-pending"
|
||||||
|
| "downloading"
|
||||||
|
| "decryption-pending"
|
||||||
|
| "decrypting"
|
||||||
|
| "decrypted"
|
||||||
|
| "canceled"
|
||||||
|
| "error";
|
||||||
|
progress?: number;
|
||||||
|
rate?: number;
|
||||||
|
estimated?: number;
|
||||||
|
result?: ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fileUploadStatusStore = writable<Writable<FileUploadStatus>[]>([]);
|
||||||
|
|
||||||
|
export const fileDownloadStatusStore = writable<Writable<FileDownloadStatus>[]>([]);
|
||||||
|
|
||||||
|
export const isFileUploading = (
|
||||||
|
status: FileUploadStatus["status"],
|
||||||
|
): status is "encryption-pending" | "encrypting" | "upload-pending" | "uploading" => {
|
||||||
|
return ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FileInfo {
|
export const isFileDownloading = (
|
||||||
id: number;
|
status: FileDownloadStatus["status"],
|
||||||
dataKey: CryptoKey;
|
): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => {
|
||||||
dataKeyVersion: Date;
|
return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
|
||||||
contentType: string;
|
};
|
||||||
contentIv: string;
|
|
||||||
name: string;
|
|
||||||
createdAt?: Date;
|
|
||||||
lastModifiedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const directoryInfoStore = new Map<"root" | number, Writable<DirectoryInfo | null>>();
|
|
||||||
|
|
||||||
export const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
|
|
||||||
|
|||||||
@@ -1,66 +1,78 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FileSaver from "file-saver";
|
import FileSaver from "file-saver";
|
||||||
import heic2any from "heic2any";
|
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import type { Writable } from "svelte/store";
|
import { get, type Writable } from "svelte/store";
|
||||||
import { TopBar } from "$lib/components";
|
import { TopBar } from "$lib/components";
|
||||||
import { getFileInfo } from "$lib/modules/file";
|
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
||||||
import { masterKeyStore, type FileInfo } from "$lib/stores";
|
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
|
||||||
|
import DownloadStatus from "./DownloadStatus.svelte";
|
||||||
import { requestFileDownload } from "./service";
|
import { requestFileDownload } from "./service";
|
||||||
|
|
||||||
type ContentType = "image" | "video";
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let info: Writable<FileInfo | null> | undefined = $state();
|
let info: Writable<FileInfo | null> | undefined = $state();
|
||||||
let isDownloaded = $state(false);
|
|
||||||
|
|
||||||
let content: Blob | undefined = $state();
|
const downloadStatus = $derived(
|
||||||
let contentUrl: string | undefined = $state();
|
$fileDownloadStatusStore.find((statusStore) => {
|
||||||
let contentType: ContentType | undefined = $state();
|
const { id, status } = get(statusStore);
|
||||||
|
return id === data.id && isFileDownloading(status);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let isDownloadRequested = $state(false);
|
||||||
|
let viewerType: "image" | "video" | undefined = $state();
|
||||||
|
let fileBlobUrl: string | undefined = $state();
|
||||||
|
|
||||||
|
const updateViewer = async (info: FileInfo, buffer: ArrayBuffer) => {
|
||||||
|
const contentType = info.contentType;
|
||||||
|
if (contentType.startsWith("image")) {
|
||||||
|
viewerType = "image";
|
||||||
|
} else if (contentType.startsWith("video")) {
|
||||||
|
viewerType = "video";
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBlob = new Blob([buffer], { type: contentType });
|
||||||
|
if (contentType === "image/heic") {
|
||||||
|
const { default: heic2any } = await import("heic2any");
|
||||||
|
fileBlobUrl = URL.createObjectURL(
|
||||||
|
(await heic2any({ blob: fileBlob, toType: "image/jpeg" })) as Blob,
|
||||||
|
);
|
||||||
|
} else if (viewerType) {
|
||||||
|
fileBlobUrl = URL.createObjectURL(fileBlob);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileBlob;
|
||||||
|
};
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
||||||
isDownloaded = false;
|
isDownloadRequested = false;
|
||||||
|
viewerType = undefined;
|
||||||
content = undefined;
|
|
||||||
contentType = undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($info && !isDownloaded) {
|
if ($info && $info.dataKey && $info.contentIv) {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
isDownloaded = true;
|
if (!downloadStatus && !isDownloadRequested) {
|
||||||
|
isDownloadRequested = true;
|
||||||
if ($info.contentType.startsWith("image")) {
|
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => {
|
||||||
contentType = "image";
|
const blob = await updateViewer($info, buffer);
|
||||||
} else if ($info.contentType.startsWith("video")) {
|
if (!viewerType) {
|
||||||
contentType = "video";
|
FileSaver.saveAs(blob, $info.name);
|
||||||
}
|
|
||||||
|
|
||||||
requestFileDownload(data.id, $info.contentIv, $info.dataKey).then(async (res) => {
|
|
||||||
content = new Blob([res], { type: $info.contentType });
|
|
||||||
if (content.type === "image/heic" || content.type === "image/heif") {
|
|
||||||
contentUrl = URL.createObjectURL(
|
|
||||||
(await heic2any({ blob: content, toType: "image/jpeg" })) as Blob,
|
|
||||||
);
|
|
||||||
} else if (contentType) {
|
|
||||||
contentUrl = URL.createObjectURL(content);
|
|
||||||
} else {
|
|
||||||
FileSaver.saveAs(content, $info.name);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
return () => {
|
if ($info && $downloadStatus?.status === "decrypted") {
|
||||||
if (contentUrl) {
|
untrack(() => !isDownloadRequested && updateViewer($info, $downloadStatus.result!));
|
||||||
URL.revokeObjectURL(contentUrl);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => () => fileBlobUrl && URL.revokeObjectURL(fileBlobUrl));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -69,23 +81,24 @@
|
|||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<TopBar title={$info?.name} />
|
<TopBar title={$info?.name} />
|
||||||
<div class="mb-4 flex w-full flex-grow flex-col items-center">
|
<DownloadStatus status={downloadStatus} />
|
||||||
|
<div class="flex w-full flex-grow flex-col items-center pb-4">
|
||||||
{#snippet viewerLoading(message: string)}
|
{#snippet viewerLoading(message: string)}
|
||||||
<div class="flex flex-grow items-center justify-center">
|
<div class="flex flex-grow items-center justify-center">
|
||||||
<p class="text-gray-500">{message}</p>
|
<p class="text-gray-500">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if $info && contentType === "image"}
|
{#if $info && viewerType === "image"}
|
||||||
{#if contentUrl}
|
{#if fileBlobUrl}
|
||||||
<img src={contentUrl} alt={$info.name} />
|
<img src={fileBlobUrl} alt={$info.name} />
|
||||||
{:else}
|
{:else}
|
||||||
{@render viewerLoading("이미지를 불러오고 있어요.")}
|
{@render viewerLoading("이미지를 불러오고 있어요.")}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if contentType === "video"}
|
{:else if viewerType === "video"}
|
||||||
{#if contentUrl}
|
{#if fileBlobUrl}
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
<video src={contentUrl} controls></video>
|
<video src={fileBlobUrl} controls></video>
|
||||||
{:else}
|
{:else}
|
||||||
{@render viewerLoading("비디오를 불러오고 있어요.")}
|
{@render viewerLoading("비디오를 불러오고 있어요.")}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { error } from "@sveltejs/kit";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const ssr = false; // Because of heic2any
|
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
export const load: PageLoad = async ({ params }) => {
|
||||||
const zodRes = z
|
const zodRes = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
33
src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte
Normal file
33
src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { formatNetworkSpeed } from "$lib/modules/util";
|
||||||
|
import { isFileDownloading, type FileDownloadStatus } from "$lib/stores";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status?: Writable<FileDownloadStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { status }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $status && isFileDownloading($status.status)}
|
||||||
|
<div class="w-full rounded-xl bg-gray-100 p-3">
|
||||||
|
<p class="font-medium">
|
||||||
|
{#if $status.status === "download-pending"}
|
||||||
|
다운로드를 기다리는 중
|
||||||
|
{:else if $status.status === "downloading"}
|
||||||
|
다운로드하는 중
|
||||||
|
{:else if $status.status === "decryption-pending"}
|
||||||
|
복호화를 기다리는 중
|
||||||
|
{:else if $status.status === "decrypting"}
|
||||||
|
복호화하는 중
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs">
|
||||||
|
{#if $status.status === "downloading"}
|
||||||
|
전송됨
|
||||||
|
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,31 +1,14 @@
|
|||||||
import { decryptData } from "$lib/modules/crypto";
|
import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file";
|
||||||
|
|
||||||
export const requestFileDownload = (
|
export const requestFileDownload = async (
|
||||||
fileId: number,
|
fileId: number,
|
||||||
fileEncryptedIv: string,
|
fileEncryptedIv: string,
|
||||||
dataKey: CryptoKey,
|
dataKey: CryptoKey,
|
||||||
) => {
|
) => {
|
||||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
const cache = await getFileCache(fileId);
|
||||||
const xhr = new XMLHttpRequest();
|
if (cache) return cache;
|
||||||
xhr.responseType = "arraybuffer";
|
|
||||||
|
|
||||||
xhr.addEventListener("load", async () => {
|
const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey);
|
||||||
if (xhr.status !== 200) {
|
storeFileCache(fileId, fileBuffer); // Intended
|
||||||
reject(new Error("Failed to download file"));
|
return fileBuffer;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileDecrypted = await decryptData(
|
|
||||||
xhr.response as ArrayBuffer,
|
|
||||||
fileEncryptedIv,
|
|
||||||
dataKey,
|
|
||||||
);
|
|
||||||
resolve(fileDecrypted);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Progress, ...
|
|
||||||
|
|
||||||
xhr.open("GET", `/api/file/${fileId}/download`);
|
|
||||||
xhr.send();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
29
src/routes/(fullscreen)/file/downloads/+page.svelte
Normal file
29
src/routes/(fullscreen)/file/downloads/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { TopBar } from "$lib/components";
|
||||||
|
import { fileDownloadStatusStore, isFileDownloading } from "$lib/stores";
|
||||||
|
import File from "./File.svelte";
|
||||||
|
|
||||||
|
const downloadingFiles = $derived(
|
||||||
|
$fileDownloadStatusStore.filter((status) => isFileDownloading(get(status).status)),
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => () => {
|
||||||
|
$fileDownloadStatusStore = $fileDownloadStatusStore.filter((status) =>
|
||||||
|
isFileDownloading(get(status).status),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>진행 중인 다운로드</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<TopBar />
|
||||||
|
<div class="space-y-2 pb-4">
|
||||||
|
{#each downloadingFiles as status}
|
||||||
|
<File {status} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
67
src/routes/(fullscreen)/file/downloads/File.svelte
Normal file
67
src/routes/(fullscreen)/file/downloads/File.svelte
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { get, type Writable } from "svelte/store";
|
||||||
|
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
||||||
|
import { formatNetworkSpeed } from "$lib/modules/util";
|
||||||
|
import { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
|
||||||
|
|
||||||
|
import IconCloud from "~icons/material-symbols/cloud";
|
||||||
|
import IconCloudDownload from "~icons/material-symbols/cloud-download";
|
||||||
|
import IconLock from "~icons/material-symbols/lock";
|
||||||
|
import IconLockClock from "~icons/material-symbols/lock-clock";
|
||||||
|
import IconCheckCircle from "~icons/material-symbols/check-circle";
|
||||||
|
import IconError from "~icons/material-symbols/error";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: Writable<FileDownloadStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { status }: Props = $props();
|
||||||
|
|
||||||
|
let fileInfo: Writable<FileInfo | null> | undefined = $state();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
fileInfo = getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $fileInfo}
|
||||||
|
<div class="flex h-14 items-center gap-x-4 p-2">
|
||||||
|
<div class="flex-shrink-0 text-lg text-gray-600">
|
||||||
|
{#if $status.status === "download-pending"}
|
||||||
|
<IconCloud />
|
||||||
|
{:else if $status.status === "downloading"}
|
||||||
|
<IconCloudDownload />
|
||||||
|
{:else if $status.status === "decryption-pending"}
|
||||||
|
<IconLock />
|
||||||
|
{:else if $status.status === "decrypting"}
|
||||||
|
<IconLockClock />
|
||||||
|
{:else if $status.status === "decrypted"}
|
||||||
|
<IconCheckCircle class="text-green-500" />
|
||||||
|
{:else if $status.status === "error"}
|
||||||
|
<IconError class="text-red-500" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow overflow-hidden">
|
||||||
|
<p title={$fileInfo.name} class="truncate font-medium">
|
||||||
|
{$fileInfo.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-800">
|
||||||
|
{#if $status.status === "download-pending"}
|
||||||
|
다운로드를 기다리는 중
|
||||||
|
{:else if $status.status === "downloading"}
|
||||||
|
전송됨
|
||||||
|
{Math.floor(($status.progress ?? 0) * 100)}% ·
|
||||||
|
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
|
||||||
|
{:else if $status.status === "decryption-pending"}
|
||||||
|
복호화를 기다리는 중
|
||||||
|
{:else if $status.status === "decrypting"}
|
||||||
|
복호화하는 중
|
||||||
|
{:else if $status.status === "decrypted"}
|
||||||
|
다운로드 완료
|
||||||
|
{:else if $status.status === "error"}
|
||||||
|
다운로드 실패
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
29
src/routes/(fullscreen)/file/uploads/+page.svelte
Normal file
29
src/routes/(fullscreen)/file/uploads/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { TopBar } from "$lib/components";
|
||||||
|
import { fileUploadStatusStore, isFileUploading } from "$lib/stores";
|
||||||
|
import File from "./File.svelte";
|
||||||
|
|
||||||
|
const uploadingFiles = $derived(
|
||||||
|
$fileUploadStatusStore.filter((status) => isFileUploading(get(status).status)),
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => () => {
|
||||||
|
$fileUploadStatusStore = $fileUploadStatusStore.filter((status) =>
|
||||||
|
isFileUploading(get(status).status),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>진행 중인 업로드</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<TopBar />
|
||||||
|
<div class="space-y-2 pb-4">
|
||||||
|
{#each uploadingFiles as status}
|
||||||
|
<File {status} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
57
src/routes/(fullscreen)/file/uploads/File.svelte
Normal file
57
src/routes/(fullscreen)/file/uploads/File.svelte
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { formatNetworkSpeed } from "$lib/modules/util";
|
||||||
|
import type { FileUploadStatus } from "$lib/stores";
|
||||||
|
|
||||||
|
import IconPending from "~icons/material-symbols/pending";
|
||||||
|
import IconLockClock from "~icons/material-symbols/lock-clock";
|
||||||
|
import IconCloud from "~icons/material-symbols/cloud";
|
||||||
|
import IconCloudUpload from "~icons/material-symbols/cloud-upload";
|
||||||
|
import IconCloudDone from "~icons/material-symbols/cloud-done";
|
||||||
|
import IconError from "~icons/material-symbols/error";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: Writable<FileUploadStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { status }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-14 items-center gap-x-4 p-2">
|
||||||
|
<div class="flex-shrink-0 text-lg text-gray-600">
|
||||||
|
{#if $status.status === "encryption-pending"}
|
||||||
|
<IconPending />
|
||||||
|
{:else if $status.status === "encrypting"}
|
||||||
|
<IconLockClock />
|
||||||
|
{:else if $status.status === "upload-pending"}
|
||||||
|
<IconCloud />
|
||||||
|
{:else if $status.status === "uploading"}
|
||||||
|
<IconCloudUpload />
|
||||||
|
{:else if $status.status === "uploaded"}
|
||||||
|
<IconCloudDone class="text-blue-500" />
|
||||||
|
{:else if $status.status === "error"}
|
||||||
|
<IconError class="text-red-500" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow overflow-hidden">
|
||||||
|
<p title={$status.name} class="truncate font-medium">
|
||||||
|
{$status.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-800">
|
||||||
|
{#if $status.status === "encryption-pending"}
|
||||||
|
준비 중
|
||||||
|
{:else if $status.status === "encrypting"}
|
||||||
|
암호화하는 중
|
||||||
|
{:else if $status.status === "upload-pending"}
|
||||||
|
업로드를 기다리는 중
|
||||||
|
{:else if $status.status === "uploading"}
|
||||||
|
전송됨
|
||||||
|
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
|
||||||
|
{:else if $status.status === "uploaded"}
|
||||||
|
업로드 완료
|
||||||
|
{:else if $status.status === "error"}
|
||||||
|
업로드 실패
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { saveAs } from "file-saver";
|
import FileSaver from "file-saver";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { Button, TextButton } from "$lib/components/buttons";
|
import { Button, TextButton } from "$lib/components/buttons";
|
||||||
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
const clientKeysBlob = new Blob([JSON.stringify(clientKeysSerialized)], {
|
const clientKeysBlob = new Blob([JSON.stringify(clientKeysSerialized)], {
|
||||||
type: "application/json",
|
type: "application/json",
|
||||||
});
|
});
|
||||||
saveAs(clientKeysBlob, "arkvault-clientkey.json");
|
FileSaver.saveAs(clientKeysBlob, "arkvault-clientkey.json");
|
||||||
|
|
||||||
if (!isBeforeContinueBottomSheetOpen) {
|
if (!isBeforeContinueBottomSheetOpen) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
74
src/routes/(fullscreen)/settings/cache/+page.svelte
vendored
Normal file
74
src/routes/(fullscreen)/settings/cache/+page.svelte
vendored
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { TopBar } from "$lib/components";
|
||||||
|
import type { FileCacheIndex } from "$lib/indexedDB";
|
||||||
|
import { getFileCacheIndex } from "$lib/modules/file";
|
||||||
|
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
||||||
|
import { formatFileSize } from "$lib/modules/util";
|
||||||
|
import { masterKeyStore } from "$lib/stores";
|
||||||
|
import File from "./File.svelte";
|
||||||
|
import { deleteFileCache as doDeleteFileCache } from "./service";
|
||||||
|
|
||||||
|
interface FileCache {
|
||||||
|
index: FileCacheIndex;
|
||||||
|
fileInfo: Writable<FileInfo | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileCache: FileCache[] | undefined = $state();
|
||||||
|
let fileCacheTotalSize = $state(0);
|
||||||
|
|
||||||
|
const deleteFileCache = async (fileId: number) => {
|
||||||
|
await doDeleteFileCache(fileId);
|
||||||
|
fileCache = fileCache?.filter(({ index }) => index.fileId !== fileId);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fileCache = getFileCacheIndex()
|
||||||
|
.map((index) => ({
|
||||||
|
index,
|
||||||
|
fileInfo: getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (fileCache) {
|
||||||
|
fileCacheTotalSize = fileCache.reduce((acc, { index }) => acc + index.size, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>캐시 설정</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<TopBar title="캐시" />
|
||||||
|
{#if fileCache && fileCache.length > 0}
|
||||||
|
<div class="space-y-4 pb-4">
|
||||||
|
<div class="space-y-1 break-keep text-gray-800">
|
||||||
|
<p>
|
||||||
|
{fileCache.length}개 파일이 캐시되어 {formatFileSize(fileCacheTotalSize)}를 사용하고
|
||||||
|
있어요.
|
||||||
|
</p>
|
||||||
|
<p>캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each fileCache as { index, fileInfo }}
|
||||||
|
<File {index} info={fileInfo} onDeleteClick={deleteFileCache} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-grow items-center justify-center">
|
||||||
|
<p class="text-gray-500">
|
||||||
|
{#if fileCache}
|
||||||
|
캐시된 파일이 없어요.
|
||||||
|
{:else}
|
||||||
|
캐시 목록을 불러오고 있어요.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
46
src/routes/(fullscreen)/settings/cache/File.svelte
vendored
Normal file
46
src/routes/(fullscreen)/settings/cache/File.svelte
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import type { FileCacheIndex } from "$lib/indexedDB";
|
||||||
|
import type { FileInfo } from "$lib/modules/filesystem";
|
||||||
|
import { formatDate, formatFileSize } from "$lib/modules/util";
|
||||||
|
|
||||||
|
import IconDraft from "~icons/material-symbols/draft";
|
||||||
|
import IconScanDelete from "~icons/material-symbols/scan-delete";
|
||||||
|
import IconDelete from "~icons/material-symbols/delete";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
index: FileCacheIndex;
|
||||||
|
info: Writable<FileInfo | null>;
|
||||||
|
onDeleteClick: (fileId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { index, info, onDeleteClick }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-14 items-center gap-x-4 p-2">
|
||||||
|
{#if $info}
|
||||||
|
<div class="flex-shrink-0 rounded-full bg-blue-100 p-1 text-xl">
|
||||||
|
<IconDraft class="text-blue-400" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex-shrink-0 rounded-full bg-red-100 p-1 text-xl">
|
||||||
|
<IconScanDelete class="text-red-400" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-grow overflow-hidden">
|
||||||
|
{#if $info}
|
||||||
|
<p title={$info.name} class="truncate font-medium">{$info.name}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="font-medium">삭제된 파일</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-xs text-gray-800">
|
||||||
|
읽음 {formatDate(index.lastRetrievedAt)} · {formatFileSize(index.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => setTimeout(() => onDeleteClick(index.fileId), 100)}
|
||||||
|
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
|
||||||
|
>
|
||||||
|
<IconDelete class="text-lg text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
5
src/routes/(fullscreen)/settings/cache/service.ts
vendored
Normal file
5
src/routes/(fullscreen)/settings/cache/service.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
|
||||||
|
|
||||||
|
export const deleteFileCache = async (fileId: number) => {
|
||||||
|
await doDeleteFileCache(fileId);
|
||||||
|
};
|
||||||
@@ -4,19 +4,20 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { TopBar } from "$lib/components";
|
import { TopBar } from "$lib/components";
|
||||||
import { FloatingButton } from "$lib/components/buttons";
|
import { FloatingButton } from "$lib/components/buttons";
|
||||||
import { getDirectoryInfo } from "$lib/modules/file";
|
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
|
||||||
import { masterKeyStore, hmacSecretStore, type DirectoryInfo } from "$lib/stores";
|
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
|
||||||
import CreateBottomSheet from "./CreateBottomSheet.svelte";
|
import CreateBottomSheet from "./CreateBottomSheet.svelte";
|
||||||
import CreateDirectoryModal from "./CreateDirectoryModal.svelte";
|
import CreateDirectoryModal from "./CreateDirectoryModal.svelte";
|
||||||
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";
|
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";
|
||||||
import DirectoryEntries from "./DirectoryEntries";
|
import DirectoryEntries from "./DirectoryEntries";
|
||||||
import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte";
|
import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte";
|
||||||
|
import DownloadStatusCard from "./DownloadStatusCard.svelte";
|
||||||
import DuplicateFileModal from "./DuplicateFileModal.svelte";
|
import DuplicateFileModal from "./DuplicateFileModal.svelte";
|
||||||
import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte";
|
import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte";
|
||||||
|
import UploadStatusCard from "./UploadStatusCard.svelte";
|
||||||
import {
|
import {
|
||||||
requestHmacSecretDownload,
|
requestHmacSecretDownload,
|
||||||
requestDirectoryCreation,
|
requestDirectoryCreation,
|
||||||
requestDuplicateFileScan,
|
|
||||||
requestFileUpload,
|
requestFileUpload,
|
||||||
requestDirectoryEntryRename,
|
requestDirectoryEntryRename,
|
||||||
requestDirectoryEntryDeletion,
|
requestDirectoryEntryDeletion,
|
||||||
@@ -25,17 +26,12 @@
|
|||||||
|
|
||||||
import IconAdd from "~icons/material-symbols/add";
|
import IconAdd from "~icons/material-symbols/add";
|
||||||
|
|
||||||
interface LoadedFile {
|
|
||||||
file: File;
|
|
||||||
fileBuffer: ArrayBuffer;
|
|
||||||
fileSigned: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let info: Writable<DirectoryInfo | null> | undefined = $state();
|
let info: Writable<DirectoryInfo | null> | undefined = $state();
|
||||||
let fileInput: HTMLInputElement | undefined = $state();
|
let fileInput: HTMLInputElement | undefined = $state();
|
||||||
let loadedFile: LoadedFile | undefined = $state();
|
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
|
||||||
|
let duplicatedFile: File | undefined = $state();
|
||||||
let selectedEntry: SelectedDirectoryEntry | undefined = $state();
|
let selectedEntry: SelectedDirectoryEntry | undefined = $state();
|
||||||
|
|
||||||
let isCreateBottomSheetOpen = $state(false);
|
let isCreateBottomSheetOpen = $state(false);
|
||||||
@@ -52,34 +48,33 @@
|
|||||||
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFile = (loadedFile: LoadedFile) => {
|
const uploadFile = () => {
|
||||||
requestFileUpload(
|
const files = fileInput?.files;
|
||||||
loadedFile.file,
|
if (!files || files.length === 0) return;
|
||||||
loadedFile.fileBuffer,
|
|
||||||
loadedFile.fileSigned,
|
|
||||||
data.id,
|
|
||||||
$masterKeyStore?.get(1)!,
|
|
||||||
$hmacSecretStore?.get(1)!,
|
|
||||||
).then(() => {
|
|
||||||
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadAndUploadFile = async () => {
|
for (const file of files) {
|
||||||
const file = fileInput?.files?.[0];
|
requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => {
|
||||||
if (!file) return;
|
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 = "";
|
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 () => {
|
onMount(async () => {
|
||||||
@@ -97,7 +92,7 @@
|
|||||||
<title>파일</title>
|
<title>파일</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<input bind:this={fileInput} onchange={loadAndUploadFile} type="file" class="hidden" />
|
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
|
||||||
|
|
||||||
<div class="flex min-h-full flex-col px-4">
|
<div class="flex min-h-full flex-col px-4">
|
||||||
{#if data.id !== "root"}
|
{#if data.id !== "root"}
|
||||||
@@ -106,6 +101,10 @@
|
|||||||
{#if $info}
|
{#if $info}
|
||||||
{@const topMargin = data.id === "root" ? "mt-4" : ""}
|
{@const topMargin = data.id === "root" ? "mt-4" : ""}
|
||||||
<div class="mb-4 flex flex-grow flex-col {topMargin}">
|
<div class="mb-4 flex flex-grow flex-col {topMargin}">
|
||||||
|
<div class="flex gap-x-2">
|
||||||
|
<UploadStatusCard onclick={() => goto("/file/uploads")} />
|
||||||
|
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
|
||||||
|
</div>
|
||||||
{#key $info}
|
{#key $info}
|
||||||
<DirectoryEntries
|
<DirectoryEntries
|
||||||
info={$info}
|
info={$info}
|
||||||
@@ -140,14 +139,18 @@
|
|||||||
<CreateDirectoryModal bind:isOpen={isCreateDirectoryModalOpen} onCreateClick={createDirectory} />
|
<CreateDirectoryModal bind:isOpen={isCreateDirectoryModalOpen} onCreateClick={createDirectory} />
|
||||||
<DuplicateFileModal
|
<DuplicateFileModal
|
||||||
bind:isOpen={isDuplicateFileModalOpen}
|
bind:isOpen={isDuplicateFileModalOpen}
|
||||||
|
file={duplicatedFile}
|
||||||
onclose={() => {
|
onclose={() => {
|
||||||
|
resolveForDuplicateFileModal?.(false);
|
||||||
|
resolveForDuplicateFileModal = undefined;
|
||||||
|
duplicatedFile = undefined;
|
||||||
isDuplicateFileModalOpen = false;
|
isDuplicateFileModalOpen = false;
|
||||||
loadedFile = undefined;
|
|
||||||
}}
|
}}
|
||||||
onDuplicateClick={() => {
|
onDuplicateClick={() => {
|
||||||
uploadFile(loadedFile!);
|
resolveForDuplicateFileModal?.(true);
|
||||||
|
resolveForDuplicateFileModal = undefined;
|
||||||
|
duplicatedFile = undefined;
|
||||||
isDuplicateFileModalOpen = false;
|
isDuplicateFileModalOpen = false;
|
||||||
loadedFile = undefined;
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import type { Writable } from "svelte/store";
|
import { get, type Writable } from "svelte/store";
|
||||||
import { getDirectoryInfo, getFileInfo } from "$lib/modules/file";
|
import {
|
||||||
import { masterKeyStore, type DirectoryInfo, type FileInfo } from "$lib/stores";
|
getDirectoryInfo,
|
||||||
|
getFileInfo,
|
||||||
|
type DirectoryInfo,
|
||||||
|
type FileInfo,
|
||||||
|
} from "$lib/modules/filesystem";
|
||||||
|
import {
|
||||||
|
fileUploadStatusStore,
|
||||||
|
isFileUploading,
|
||||||
|
masterKeyStore,
|
||||||
|
type FileUploadStatus,
|
||||||
|
} from "$lib/stores";
|
||||||
import File from "./File.svelte";
|
import File from "./File.svelte";
|
||||||
import SubDirectory from "./SubDirectory.svelte";
|
import SubDirectory from "./SubDirectory.svelte";
|
||||||
import { SortBy, sortEntries } from "./service";
|
import { SortBy, sortEntries } from "./service";
|
||||||
|
import UploadingFile from "./UploadingFile.svelte";
|
||||||
import type { SelectedDirectoryEntry } from "../service";
|
import type { SelectedDirectoryEntry } from "../service";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,37 +28,95 @@
|
|||||||
|
|
||||||
let { info, onEntryClick, onEntryMenuClick, sortBy = SortBy.NAME_ASC }: Props = $props();
|
let { info, onEntryClick, onEntryMenuClick, sortBy = SortBy.NAME_ASC }: Props = $props();
|
||||||
|
|
||||||
let subDirectoryInfos: Writable<DirectoryInfo | null>[] = $state([]);
|
interface DirectoryEntry {
|
||||||
let fileInfos: Writable<FileInfo | null>[] = $state([]);
|
name?: string;
|
||||||
|
info: Writable<DirectoryInfo | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileEntry =
|
||||||
|
| {
|
||||||
|
type: "file";
|
||||||
|
name?: string;
|
||||||
|
info: Writable<FileInfo | null>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "uploading-file";
|
||||||
|
name: string;
|
||||||
|
info: Writable<FileUploadStatus>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let subDirectories: DirectoryEntry[] = $state([]);
|
||||||
|
let files: FileEntry[] = $state([]);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// TODO: Fix duplicated requests
|
// TODO: Fix duplicated requests
|
||||||
|
|
||||||
subDirectoryInfos = info.subDirectoryIds.map((id) =>
|
subDirectories = info.subDirectoryIds.map((id) => {
|
||||||
getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!),
|
const info = getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!);
|
||||||
|
return { name: get(info)?.name, info };
|
||||||
|
});
|
||||||
|
files = info.fileIds
|
||||||
|
.map((id): FileEntry => {
|
||||||
|
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
|
||||||
|
return {
|
||||||
|
type: "file",
|
||||||
|
name: get(info)?.name,
|
||||||
|
info,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.concat(
|
||||||
|
$fileUploadStatusStore
|
||||||
|
.filter((statusStore) => {
|
||||||
|
const { parentId, status } = get(statusStore);
|
||||||
|
return parentId === info.id && isFileUploading(status);
|
||||||
|
})
|
||||||
|
.map((status) => ({
|
||||||
|
type: "uploading-file",
|
||||||
|
name: get(status).name,
|
||||||
|
info: status,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
fileInfos = info.fileIds.map((id) => getFileInfo(id, $masterKeyStore?.get(1)?.key!));
|
|
||||||
|
|
||||||
const sort = () => {
|
const sort = () => {
|
||||||
sortEntries(subDirectoryInfos, sortBy);
|
sortEntries(subDirectories, sortBy);
|
||||||
sortEntries(fileInfos, sortBy);
|
sortEntries(files, sortBy);
|
||||||
};
|
};
|
||||||
return untrack(() => {
|
return untrack(() => {
|
||||||
const unsubscribes = subDirectoryInfos
|
sort();
|
||||||
.map((subDirectoryInfo) => subDirectoryInfo.subscribe(sort))
|
|
||||||
.concat(fileInfos.map((fileInfo) => fileInfo.subscribe(sort)));
|
const unsubscribes = subDirectories
|
||||||
|
.map((subDirectory) =>
|
||||||
|
subDirectory.info.subscribe((value) => {
|
||||||
|
if (subDirectory.name === value?.name) return;
|
||||||
|
subDirectory.name = value?.name;
|
||||||
|
sort();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.concat(
|
||||||
|
files.map((file) =>
|
||||||
|
file.info.subscribe((value) => {
|
||||||
|
if (file.name === value?.name) return;
|
||||||
|
file.name = value?.name;
|
||||||
|
sort();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
|
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if info.subDirectoryIds.length + info.fileIds.length > 0}
|
{#if subDirectories.length + files.length > 0}
|
||||||
<div class="pb-[4.5rem]">
|
<div class="space-y-1 pb-[4.5rem]">
|
||||||
{#each subDirectoryInfos as subDirectory}
|
{#each subDirectories as { info }}
|
||||||
<SubDirectory info={subDirectory} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
|
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
|
||||||
{/each}
|
{/each}
|
||||||
{#each fileInfos as file}
|
{#each files as file}
|
||||||
<File info={file} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
|
{#if file.type === "file"}
|
||||||
|
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
|
||||||
|
{:else}
|
||||||
|
<UploadingFile status={file.info} />
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import type { FileInfo } from "$lib/stores";
|
import type { FileInfo } from "$lib/modules/filesystem";
|
||||||
import { formatDate } from "./service";
|
import { formatDateTime } from "$lib/modules/util";
|
||||||
import type { SelectedDirectoryEntry } from "../service";
|
import type { SelectedDirectoryEntry } from "../service";
|
||||||
|
|
||||||
import IconDraft from "~icons/material-symbols/draft";
|
import IconDraft from "~icons/material-symbols/draft";
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
|
|
||||||
const openFile = () => {
|
const openFile = () => {
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info!;
|
const { id, dataKey, dataKeyVersion, name } = $info!;
|
||||||
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
|
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -26,6 +28,8 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info!;
|
const { id, dataKey, dataKeyVersion, name } = $info!;
|
||||||
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
|
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -40,11 +44,13 @@
|
|||||||
<div class="flex-shrink-0 text-lg">
|
<div class="flex-shrink-0 text-lg">
|
||||||
<IconDraft class="text-blue-400" />
|
<IconDraft class="text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-grow flex-col overflow-hidden">
|
<div class="flex-grow overflow-hidden">
|
||||||
<p title={$info.name} class="truncate font-medium">
|
<p title={$info.name} class="truncate font-medium">
|
||||||
{$info.name}
|
{$info.name}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-800">{formatDate($info.createdAt ?? $info.lastModifiedAt)}</p>
|
<p class="text-xs text-gray-800">
|
||||||
|
{formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
id="open-menu"
|
id="open-menu"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import type { DirectoryInfo } from "$lib/stores";
|
import type { DirectoryInfo } from "$lib/modules/filesystem";
|
||||||
import type { SelectedDirectoryEntry } from "../service";
|
import type { SelectedDirectoryEntry } from "../service";
|
||||||
|
|
||||||
import IconFolder from "~icons/material-symbols/folder";
|
import IconFolder from "~icons/material-symbols/folder";
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
const openDirectory = () => {
|
const openDirectory = () => {
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
||||||
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -27,6 +29,8 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
||||||
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { formatNetworkSpeed } from "$lib/modules/util";
|
||||||
|
import { isFileUploading, type FileUploadStatus } from "$lib/stores";
|
||||||
|
|
||||||
|
import IconDraft from "~icons/material-symbols/draft";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: Writable<FileUploadStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { status }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isFileUploading($status.status)}
|
||||||
|
<div class="flex h-14 items-center gap-x-4 p-2">
|
||||||
|
<div class="flex-shrink-0 text-lg">
|
||||||
|
<IconDraft class="text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-grow flex-col overflow-hidden text-gray-800">
|
||||||
|
<p title={$status.name} class="truncate font-medium">
|
||||||
|
{$status.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs">
|
||||||
|
{#if $status.status === "encryption-pending"}
|
||||||
|
준비 중
|
||||||
|
{:else if $status.status === "encrypting"}
|
||||||
|
암호화하는 중
|
||||||
|
{:else if $status.status === "upload-pending"}
|
||||||
|
업로드를 기다리는 중
|
||||||
|
{:else if $status.status === "uploading"}
|
||||||
|
전송됨 {Math.floor(($status.progress ?? 0) * 100)}% ·
|
||||||
|
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
import { get, type Writable } from "svelte/store";
|
|
||||||
import type { DirectoryInfo, FileInfo } from "$lib/stores";
|
|
||||||
|
|
||||||
export enum SortBy {
|
export enum SortBy {
|
||||||
NAME_ASC,
|
NAME_ASC,
|
||||||
NAME_DESC,
|
NAME_DESC,
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortFunc = (a: DirectoryInfo | FileInfo | null, b: DirectoryInfo | FileInfo | null) => number;
|
type SortFunc = (a?: string, b?: string) => number;
|
||||||
|
|
||||||
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||||
|
|
||||||
const sortByNameAsc: SortFunc = (a, b) => {
|
const sortByNameAsc: SortFunc = (a, b) => {
|
||||||
if (a && b) return a.name!.localeCompare(b.name!);
|
if (a && b) return collator.compare(a, b);
|
||||||
|
if (a) return -1;
|
||||||
|
if (b) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
|
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
|
||||||
|
|
||||||
export const sortEntries = <T extends DirectoryInfo | FileInfo>(
|
export const sortEntries = <T extends { name?: string }>(
|
||||||
entries: Writable<T | null>[],
|
entries: T[],
|
||||||
sortBy: SortBy = SortBy.NAME_ASC,
|
sortBy: SortBy = SortBy.NAME_ASC,
|
||||||
) => {
|
) => {
|
||||||
let sortFunc: SortFunc;
|
let sortFunc: SortFunc;
|
||||||
@@ -26,17 +27,5 @@ export const sortEntries = <T extends DirectoryInfo | FileInfo>(
|
|||||||
sortFunc = sortByNameDesc;
|
sortFunc = sortByNameDesc;
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.sort((a, b) => sortFunc(get(a), get(b)));
|
entries.sort((a, b) => sortFunc(a.name, b.name));
|
||||||
};
|
|
||||||
|
|
||||||
const pad2 = (num: number) => num.toString().padStart(2, "0");
|
|
||||||
|
|
||||||
export const formatDate = (date: Date) => {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = date.getMonth() + 1;
|
|
||||||
const day = date.getDate();
|
|
||||||
const hours = date.getHours();
|
|
||||||
const minutes = date.getMinutes();
|
|
||||||
|
|
||||||
return `${year}. ${month}. ${day}. ${pad2(hours)}:${pad2(minutes)}`;
|
|
||||||
};
|
};
|
||||||
|
|||||||
41
src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte
Normal file
41
src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { get, type Writable } from "svelte/store";
|
||||||
|
import { fileDownloadStatusStore, isFileDownloading, type FileDownloadStatus } from "$lib/stores";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onclick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onclick }: Props = $props();
|
||||||
|
|
||||||
|
let downloadingFiles: Writable<FileDownloadStatus>[] = $state([]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
downloadingFiles = $fileDownloadStatusStore.filter((status) =>
|
||||||
|
isFileDownloading(get(status).status),
|
||||||
|
);
|
||||||
|
return untrack(() => {
|
||||||
|
const unsubscribes = downloadingFiles.map((downloadingFile) =>
|
||||||
|
downloadingFile.subscribe(({ status }) => {
|
||||||
|
if (!isFileDownloading(status)) {
|
||||||
|
downloadingFiles = downloadingFiles.filter((file) => file !== downloadingFile);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if downloadingFiles.length > 0}
|
||||||
|
<button
|
||||||
|
onclick={() => setTimeout(onclick, 100)}
|
||||||
|
class="mb-4 max-w-[50%] flex-1 rounded-xl bg-green-100 p-3 active:bg-green-200"
|
||||||
|
>
|
||||||
|
<div class="text-left transition active:scale-95">
|
||||||
|
<p class="text-xs text-gray-800">진행 중인 다운로드</p>
|
||||||
|
<p class="font-medium text-green-800">{downloadingFiles.length}개</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
@@ -3,30 +3,28 @@
|
|||||||
import { Button } from "$lib/components/buttons";
|
import { Button } from "$lib/components/buttons";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
file: File | undefined;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
onDuplicateClick: () => void;
|
onDuplicateClick: () => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onclose, onDuplicateClick, isOpen = $bindable() }: Props = $props();
|
let { file, onclose, onDuplicateClick, isOpen = $bindable() }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal bind:isOpen {onclose}>
|
<Modal bind:isOpen {onclose}>
|
||||||
|
{#if file}
|
||||||
|
{@const { name } = file}
|
||||||
|
{@const nameShort = name.length > 20 ? `${name.slice(0, 20)}...` : name}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="space-y-2 break-keep">
|
<div class="space-y-2 break-keep">
|
||||||
<p class="text-xl font-bold">이미 업로드된 파일이에요.</p>
|
<p class="text-xl font-bold">'{nameShort}' 파일이 있어요.</p>
|
||||||
<p>그래도 업로드할까요?</p>
|
<p>예전에 이미 업로드된 파일이에요. 그래도 업로드할까요?</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button color="gray" onclick={onclose}>아니요</Button>
|
||||||
color="gray"
|
|
||||||
onclick={() => {
|
|
||||||
isOpen = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
아니요
|
|
||||||
</Button>
|
|
||||||
<Button onclick={onDuplicateClick}>업로드할게요</Button>
|
<Button onclick={onDuplicateClick}>업로드할게요</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
39
src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte
Normal file
39
src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { get, type Writable } from "svelte/store";
|
||||||
|
import { fileUploadStatusStore, isFileUploading, type FileUploadStatus } from "$lib/stores";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onclick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onclick }: Props = $props();
|
||||||
|
|
||||||
|
let uploadingFiles: Writable<FileUploadStatus>[] = $state([]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
uploadingFiles = $fileUploadStatusStore.filter((status) => isFileUploading(get(status).status));
|
||||||
|
return untrack(() => {
|
||||||
|
const unsubscribes = uploadingFiles.map((uploadingFile) =>
|
||||||
|
uploadingFile.subscribe(({ status }) => {
|
||||||
|
if (!isFileUploading(status)) {
|
||||||
|
uploadingFiles = uploadingFiles.filter((file) => file !== uploadingFile);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if uploadingFiles.length > 0}
|
||||||
|
<button
|
||||||
|
onclick={() => setTimeout(onclick, 100)}
|
||||||
|
class="mb-4 max-w-[50%] flex-1 rounded-xl bg-blue-100 p-3 active:bg-blue-200"
|
||||||
|
>
|
||||||
|
<div class="text-left transition active:scale-95">
|
||||||
|
<p class="text-xs text-gray-800">진행 중인 업로드</p>
|
||||||
|
<p class="font-medium text-blue-800">{uploadingFiles.length}개</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
@@ -1,23 +1,13 @@
|
|||||||
import ExifReader from "exifreader";
|
|
||||||
import { callGetApi, callPostApi } from "$lib/hooks";
|
import { callGetApi, callPostApi } from "$lib/hooks";
|
||||||
import { storeHmacSecrets } from "$lib/indexedDB";
|
import { storeHmacSecrets } from "$lib/indexedDB";
|
||||||
import {
|
import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
|
||||||
encodeToBase64,
|
import { deleteFileCache, uploadFile } from "$lib/modules/file";
|
||||||
generateDataKey,
|
|
||||||
wrapDataKey,
|
|
||||||
unwrapHmacSecret,
|
|
||||||
encryptData,
|
|
||||||
encryptString,
|
|
||||||
signMessageHmac,
|
|
||||||
} from "$lib/modules/crypto";
|
|
||||||
import type {
|
import type {
|
||||||
DirectoryRenameRequest,
|
DirectoryRenameRequest,
|
||||||
DirectoryCreateRequest,
|
DirectoryCreateRequest,
|
||||||
FileRenameRequest,
|
FileRenameRequest,
|
||||||
FileUploadRequest,
|
|
||||||
HmacSecretListResponse,
|
HmacSecretListResponse,
|
||||||
DuplicateFileScanRequest,
|
DirectoryDeleteResponse,
|
||||||
DuplicateFileScanResponse,
|
|
||||||
} from "$lib/server/schemas";
|
} from "$lib/server/schemas";
|
||||||
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
|
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
|
||||||
|
|
||||||
@@ -57,7 +47,7 @@ export const requestDirectoryCreation = async (
|
|||||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||||
const nameEncrypted = await encryptString(name, dataKey);
|
const nameEncrypted = await encryptString(name, dataKey);
|
||||||
await callPostApi<DirectoryCreateRequest>("/api/directory/create", {
|
await callPostApi<DirectoryCreateRequest>("/api/directory/create", {
|
||||||
parentId,
|
parent: parentId,
|
||||||
mekVersion: masterKey.version,
|
mekVersion: masterKey.version,
|
||||||
dek: await wrapDataKey(dataKey, masterKey.key),
|
dek: await wrapDataKey(dataKey, masterKey.key),
|
||||||
dekVersion: dataKeyVersion.toISOString(),
|
dekVersion: dataKeyVersion.toISOString(),
|
||||||
@@ -66,106 +56,14 @@ export const requestDirectoryCreation = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requestDuplicateFileScan = async (file: File, hmacSecret: HmacSecret) => {
|
|
||||||
const fileBuffer = await file.arrayBuffer();
|
|
||||||
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
|
|
||||||
const res = await callPostApi<DuplicateFileScanRequest>("/api/file/scanDuplicates", {
|
|
||||||
hskVersion: hmacSecret.version,
|
|
||||||
contentHmac: fileSigned,
|
|
||||||
});
|
|
||||||
if (!res.ok) return null;
|
|
||||||
|
|
||||||
const { files }: DuplicateFileScanResponse = await res.json();
|
|
||||||
return {
|
|
||||||
fileBuffer,
|
|
||||||
fileSigned,
|
|
||||||
isDuplicate: files.length > 0,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
|
|
||||||
const exif = ExifReader.load(fileBuffer);
|
|
||||||
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
|
|
||||||
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
|
|
||||||
if (!dateTimeOriginal) return undefined;
|
|
||||||
|
|
||||||
const [date, time] = dateTimeOriginal.split(" ");
|
|
||||||
if (!date || !time) return undefined;
|
|
||||||
|
|
||||||
const [year, month, day] = date.split(":").map(Number);
|
|
||||||
const [hour, minute, second] = time.split(":").map(Number);
|
|
||||||
if (!year || !month || !day || !hour || !minute || !second) return undefined;
|
|
||||||
|
|
||||||
if (!offsetTimeOriginal) {
|
|
||||||
// No timezone information -> Local timezone
|
|
||||||
return new Date(year, month - 1, day, hour, minute, second);
|
|
||||||
}
|
|
||||||
|
|
||||||
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
|
|
||||||
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
|
|
||||||
|
|
||||||
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
||||||
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
|
|
||||||
return new Date(utcDate - offsetMs);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requestFileUpload = async (
|
export const requestFileUpload = async (
|
||||||
file: File,
|
file: File,
|
||||||
fileBuffer: ArrayBuffer,
|
|
||||||
fileSigned: string,
|
|
||||||
parentId: "root" | number,
|
parentId: "root" | number,
|
||||||
masterKey: MasterKey,
|
|
||||||
hmacSecret: HmacSecret,
|
hmacSecret: HmacSecret,
|
||||||
|
masterKey: MasterKey,
|
||||||
|
onDuplicate: () => Promise<boolean>,
|
||||||
) => {
|
) => {
|
||||||
let createdAt = undefined;
|
return await uploadFile(file, parentId, hmacSecret, masterKey, onDuplicate);
|
||||||
if (file.type.startsWith("image/")) {
|
|
||||||
createdAt = extractExifDateTime(fileBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
|
||||||
const fileEncrypted = await encryptData(fileBuffer, dataKey);
|
|
||||||
const nameEncrypted = await encryptString(file.name, dataKey);
|
|
||||||
const createdAtEncrypted =
|
|
||||||
createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
|
|
||||||
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
form.set(
|
|
||||||
"metadata",
|
|
||||||
JSON.stringify({
|
|
||||||
parentId,
|
|
||||||
mekVersion: masterKey.version,
|
|
||||||
dek: await wrapDataKey(dataKey, masterKey.key),
|
|
||||||
dekVersion: dataKeyVersion.toISOString(),
|
|
||||||
hskVersion: hmacSecret.version,
|
|
||||||
contentHmac: fileSigned,
|
|
||||||
contentType: file.type,
|
|
||||||
contentIv: fileEncrypted.iv,
|
|
||||||
name: nameEncrypted.ciphertext,
|
|
||||||
nameIv: nameEncrypted.iv,
|
|
||||||
createdAt: createdAtEncrypted?.ciphertext,
|
|
||||||
createdAtIv: createdAtEncrypted?.iv,
|
|
||||||
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
|
|
||||||
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
|
|
||||||
} satisfies FileUploadRequest),
|
|
||||||
);
|
|
||||||
form.set("content", new Blob([fileEncrypted.ciphertext]));
|
|
||||||
|
|
||||||
return new Promise<void>((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);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requestDirectoryEntryRename = async (
|
export const requestDirectoryEntryRename = async (
|
||||||
@@ -190,5 +88,15 @@ export const requestDirectoryEntryRename = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const requestDirectoryEntryDeletion = async (entry: SelectedDirectoryEntry) => {
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { EntryButton } from "$lib/components/buttons";
|
import MenuEntryButton from "./MenuEntryButton.svelte";
|
||||||
import { requestLogout } from "./service.js";
|
import { requestLogout } from "./service";
|
||||||
|
|
||||||
|
import IconStorage from "~icons/material-symbols/storage";
|
||||||
import IconPassword from "~icons/material-symbols/password";
|
import IconPassword from "~icons/material-symbols/password";
|
||||||
import IconLogout from "~icons/material-symbols/logout";
|
import IconLogout from "~icons/material-symbols/logout";
|
||||||
|
|
||||||
@@ -23,23 +24,27 @@
|
|||||||
<p class="font-semibold">{data.nickname}</p>
|
<p class="font-semibold">{data.nickname}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4 px-4 pb-4">
|
<div class="space-y-4 px-4 pb-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="font-semibold">설정</p>
|
||||||
|
<MenuEntryButton
|
||||||
|
onclick={() => goto("/settings/cache")}
|
||||||
|
icon={IconStorage}
|
||||||
|
iconColor="text-green-500"
|
||||||
|
>
|
||||||
|
캐시
|
||||||
|
</MenuEntryButton>
|
||||||
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="font-semibold">보안</p>
|
<p class="font-semibold">보안</p>
|
||||||
<EntryButton onclick={() => goto("/auth/changePassword")}>
|
<MenuEntryButton
|
||||||
<div class="flex items-center gap-x-4">
|
onclick={() => goto("/auth/changePassword")}
|
||||||
<div class="rounded-lg bg-gray-200 p-1 text-blue-500">
|
icon={IconPassword}
|
||||||
<IconPassword />
|
iconColor="text-blue-500"
|
||||||
</div>
|
>
|
||||||
<p class="font-medium">비밀번호 바꾸기</p>
|
비밀번호 바꾸기
|
||||||
</div>
|
</MenuEntryButton>
|
||||||
</EntryButton>
|
<MenuEntryButton onclick={logout} icon={IconLogout} iconColor="text-red-500">
|
||||||
<EntryButton onclick={logout}>
|
로그아웃
|
||||||
<div class="flex items-center gap-x-4">
|
</MenuEntryButton>
|
||||||
<div class="rounded-lg bg-gray-200 p-1 text-red-500">
|
|
||||||
<IconLogout />
|
|
||||||
</div>
|
|
||||||
<p class="font-medium">로그아웃</p>
|
|
||||||
</div>
|
|
||||||
</EntryButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
25
src/routes/(main)/menu/MenuEntryButton.svelte
Normal file
25
src/routes/(main)/menu/MenuEntryButton.svelte
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Component, Snippet } from "svelte";
|
||||||
|
import type { SvelteHTMLElements } from "svelte/elements";
|
||||||
|
import { EntryButton } from "$lib/components/buttons";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
icon: Component<SvelteHTMLElements["svg"]>;
|
||||||
|
iconColor: string;
|
||||||
|
onclick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, icon: Icon, iconColor, onclick }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EntryButton {onclick}>
|
||||||
|
<div class="flex items-center gap-x-4">
|
||||||
|
<div class="rounded-lg bg-gray-200 p-1 {iconColor}">
|
||||||
|
<Icon />
|
||||||
|
</div>
|
||||||
|
<p class="font-medium">
|
||||||
|
{@render children?.()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</EntryButton>
|
||||||
@@ -1,11 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { get } from "svelte/store";
|
||||||
import { goto as svelteGoto } from "$app/navigation";
|
import { goto as svelteGoto } from "$app/navigation";
|
||||||
import { clientKeyStore, masterKeyStore } from "$lib/stores";
|
import {
|
||||||
|
fileUploadStatusStore,
|
||||||
|
fileDownloadStatusStore,
|
||||||
|
isFileUploading,
|
||||||
|
isFileDownloading,
|
||||||
|
clientKeyStore,
|
||||||
|
masterKeyStore,
|
||||||
|
} from "$lib/stores";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (
|
||||||
|
$fileUploadStatusStore.some((status) => isFileUploading(get(status).status)) ||
|
||||||
|
$fileDownloadStatusStore.some((status) => isFileDownloading(get(status).status))
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const goto = async (url: string) => {
|
const goto = async (url: string) => {
|
||||||
const whitelist = ["/auth/login", "/key", "/client/pending"];
|
const whitelist = ["/auth/login", "/key", "/client/pending"];
|
||||||
@@ -24,4 +41,6 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onbeforeunload={protectFileUploadAndDownload} />
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
|
|||||||
return json(
|
return json(
|
||||||
directoryInfoResponse.parse({
|
directoryInfoResponse.parse({
|
||||||
metadata: metadata && {
|
metadata: metadata && {
|
||||||
|
parent: metadata.parentId,
|
||||||
mekVersion: metadata.mekVersion,
|
mekVersion: metadata.mekVersion,
|
||||||
dek: metadata.encDek,
|
dek: metadata.encDek,
|
||||||
dekVersion: metadata.dekVersion.toISOString(),
|
dekVersion: metadata.dekVersion.toISOString(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { error, text } from "@sveltejs/kit";
|
import { error, json } from "@sveltejs/kit";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { authorize } from "$lib/server/modules/auth";
|
import { authorize } from "$lib/server/modules/auth";
|
||||||
|
import { directoryDeleteResponse, type DirectoryDeleteResponse } from "$lib/server/schemas";
|
||||||
import { deleteDirectory } from "$lib/server/services/directory";
|
import { deleteDirectory } from "$lib/server/services/directory";
|
||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ export const POST: RequestHandler = async ({ locals, params }) => {
|
|||||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||||
const { id } = zodRes.data;
|
const { id } = zodRes.data;
|
||||||
|
|
||||||
await deleteDirectory(userId, id);
|
const { files } = await deleteDirectory(userId, id);
|
||||||
return text("Directory deleted", { headers: { "Content-Type": "text/plain" } });
|
return json(
|
||||||
|
directoryDeleteResponse.parse({ deletedFiles: files } satisfies DirectoryDeleteResponse),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ export const POST: RequestHandler = async ({ locals, request }) => {
|
|||||||
|
|
||||||
const zodRes = directoryCreateRequest.safeParse(await request.json());
|
const zodRes = directoryCreateRequest.safeParse(await request.json());
|
||||||
if (!zodRes.success) error(400, "Invalid request body");
|
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({
|
await createDirectory({
|
||||||
userId,
|
userId,
|
||||||
parentId,
|
parentId: parent,
|
||||||
mekVersion,
|
mekVersion,
|
||||||
encDek: dek,
|
encDek: dek,
|
||||||
dekVersion: new Date(dekVersion),
|
dekVersion: new Date(dekVersion),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
|
|||||||
const { id } = zodRes.data;
|
const { id } = zodRes.data;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
parentId,
|
||||||
mekVersion,
|
mekVersion,
|
||||||
encDek,
|
encDek,
|
||||||
dekVersion,
|
dekVersion,
|
||||||
@@ -28,6 +29,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
|
|||||||
} = await getFileInformation(userId, id);
|
} = await getFileInformation(userId, id);
|
||||||
return json(
|
return json(
|
||||||
fileInfoResponse.parse({
|
fileInfoResponse.parse({
|
||||||
|
parent: parentId,
|
||||||
mekVersion,
|
mekVersion,
|
||||||
dek: encDek,
|
dek: encDek,
|
||||||
dekVersion: dekVersion.toISOString(),
|
dekVersion: dekVersion.toISOString(),
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
|
import Busboy from "@fastify/busboy";
|
||||||
import { error, text } from "@sveltejs/kit";
|
import { error, text } from "@sveltejs/kit";
|
||||||
|
import { Readable, Writable } from "stream";
|
||||||
import { authorize } from "$lib/server/modules/auth";
|
import { authorize } from "$lib/server/modules/auth";
|
||||||
import { fileUploadRequest } from "$lib/server/schemas";
|
import { fileUploadRequest } from "$lib/server/schemas";
|
||||||
import { uploadFile } from "$lib/server/services/file";
|
import { uploadFile } from "$lib/server/services/file";
|
||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ locals, request }) => {
|
type FileMetadata = Parameters<typeof uploadFile>[0];
|
||||||
const { userId } = await authorize(locals, "activeClient");
|
|
||||||
|
|
||||||
const form = await request.formData();
|
const parseFileMetadata = (userId: number, json: string) => {
|
||||||
const metadata = form.get("metadata");
|
const zodRes = fileUploadRequest.safeParse(JSON.parse(json));
|
||||||
const content = form.get("content");
|
|
||||||
if (typeof metadata !== "string" || !(content instanceof File)) {
|
|
||||||
error(400, "Invalid request body");
|
|
||||||
}
|
|
||||||
|
|
||||||
const zodRes = fileUploadRequest.safeParse(JSON.parse(metadata));
|
|
||||||
if (!zodRes.success) error(400, "Invalid request body");
|
if (!zodRes.success) error(400, "Invalid request body");
|
||||||
const {
|
const {
|
||||||
parentId,
|
parent,
|
||||||
mekVersion,
|
mekVersion,
|
||||||
dek,
|
dek,
|
||||||
dekVersion,
|
dekVersion,
|
||||||
@@ -35,10 +30,9 @@ export const POST: RequestHandler = async ({ locals, request }) => {
|
|||||||
if ((createdAt && !createdAtIv) || (!createdAt && createdAtIv))
|
if ((createdAt && !createdAtIv) || (!createdAt && createdAtIv))
|
||||||
error(400, "Invalid request body");
|
error(400, "Invalid request body");
|
||||||
|
|
||||||
await uploadFile(
|
return {
|
||||||
{
|
|
||||||
userId,
|
userId,
|
||||||
parentId,
|
parentId: parent,
|
||||||
mekVersion,
|
mekVersion,
|
||||||
encDek: dek,
|
encDek: dek,
|
||||||
dekVersion: new Date(dekVersion),
|
dekVersion: new Date(dekVersion),
|
||||||
@@ -52,8 +46,61 @@ export const POST: RequestHandler = async ({ locals, request }) => {
|
|||||||
encCreatedAtIv: createdAtIv ?? null,
|
encCreatedAtIv: createdAtIv ?? null,
|
||||||
encLastModifiedAt: lastModifiedAt,
|
encLastModifiedAt: lastModifiedAt,
|
||||||
encLastModifiedAtIv: lastModifiedAtIv,
|
encLastModifiedAtIv: lastModifiedAtIv,
|
||||||
},
|
} satisfies FileMetadata;
|
||||||
content.stream(),
|
};
|
||||||
);
|
|
||||||
return text("File uploaded", { headers: { "Content-Type": "text/plain" } });
|
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<Response>((resolve, reject) => {
|
||||||
|
const bb = Busboy({ headers: { "content-type": contentType } });
|
||||||
|
const handler =
|
||||||
|
<T extends unknown[]>(f: (...args: T) => Promise<void>) =>
|
||||||
|
(...args: T) => {
|
||||||
|
f(...args).catch(reject);
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata: FileMetadata | null = null;
|
||||||
|
let content: Readable | null = null;
|
||||||
|
const checksum = new Promise<string>((resolveChecksum, rejectChecksum) => {
|
||||||
|
bb.on(
|
||||||
|
"field",
|
||||||
|
handler(async (fieldname, val) => {
|
||||||
|
if (fieldname === "metadata") {
|
||||||
|
if (!metadata) {
|
||||||
|
// Ignore subsequent metadata fields
|
||||||
|
metadata = parseFileMetadata(userId, val);
|
||||||
|
}
|
||||||
|
} else if (fieldname === "checksum") {
|
||||||
|
resolveChecksum(val); // Ignore subsequent checksum fields
|
||||||
|
} else {
|
||||||
|
error(400, "Invalid request body");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
bb.on(
|
||||||
|
"file",
|
||||||
|
handler(async (fieldname, file) => {
|
||||||
|
if (fieldname !== "content") error(400, "Invalid request body");
|
||||||
|
if (!metadata || content) error(400, "Invalid request body");
|
||||||
|
content = file;
|
||||||
|
|
||||||
|
await uploadFile(metadata, content, checksum);
|
||||||
|
resolve(text("File uploaded", { headers: { "Content-Type": "text/plain" } }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
bb.on("finish", () => rejectChecksum(new Error("Invalid request body")));
|
||||||
|
bb.on("error", (e) => {
|
||||||
|
content?.emit("error", e) ?? reject(e);
|
||||||
|
rejectChecksum(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
request.body!.pipeTo(Writable.toWeb(bb)).catch(() => {}); // busboy will handle the error
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user