From 937c4e2453e837ae6373ac911934df99dcbcb5f8 Mon Sep 17 00:00:00 2001 From: static Date: Thu, 16 Jan 2025 02:33:00 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 암호화는 동시에 최대 4개까지, 업로드는 1개까지 가능하도록 설정했습니다. --- package.json | 2 + pnpm-lock.yaml | 90 +++++++ src/hooks.client.ts | 2 +- src/lib/modules/{ => file}/cache.ts | 0 src/lib/modules/file/index.ts | 3 + src/lib/modules/{file.ts => file/info.ts} | 0 src/lib/modules/file/upload.ts | 221 ++++++++++++++++++ src/lib/stores/file.ts | 20 +- src/routes/(fullscreen)/file/[id]/service.ts | 2 +- .../(fullscreen)/setting/cache/+page.svelte | 3 +- .../(fullscreen)/setting/cache/service.ts | 2 +- .../(main)/directory/[[id]]/+page.svelte | 61 ++--- .../[[id]]/DuplicateFileModal.svelte | 9 +- src/routes/(main)/directory/[[id]]/service.ts | 114 +-------- 14 files changed, 367 insertions(+), 162 deletions(-) rename src/lib/modules/{ => file}/cache.ts (100%) create mode 100644 src/lib/modules/file/index.ts rename src/lib/modules/{file.ts => file/info.ts} (100%) create mode 100644 src/lib/modules/file/upload.ts diff --git a/package.json b/package.json index 91c3c3e..3a4adf2 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@types/ms": "^0.7.34", "@types/node-schedule": "^2.1.7", "autoprefixer": "^10.4.20", + "axios": "^1.7.9", "dexie": "^4.0.10", "drizzle-kit": "^0.22.8", "eslint": "^9.17.0", @@ -38,6 +39,7 @@ "globals": "^15.14.0", "heic2any": "^0.0.4", "mime": "^4.0.6", + "p-limit": "^6.2.0", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.2", "prettier-plugin-tailwindcss": "^0.6.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43441bd..ae948dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.49) + axios: + specifier: ^1.7.9 + version: 1.7.9 dexie: specifier: ^4.0.10 version: 4.0.10 @@ -96,6 +99,9 @@ importers: mime: specifier: ^4.0.6 version: 4.0.6 + p-limit: + specifier: ^6.2.0 + version: 6.2.0 prettier: specifier: ^3.4.2 version: 3.4.2 @@ -975,6 +981,9 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + autoprefixer@10.4.20: resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} engines: {node: ^10 || ^12 || >=14} @@ -982,6 +991,9 @@ packages: peerDependencies: postcss: ^8.1.0 + axios@1.7.9: + resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -1063,6 +1075,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1117,6 +1133,10 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-libc@2.0.3: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} @@ -1412,10 +1432,23 @@ packages: flatted@3.3.2: resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.0: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} + form-data@4.0.1: + resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} + engines: {node: '>= 6'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -1619,6 +1652,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime@4.0.6: resolution: {integrity: sha512-4rGt7rvQHBbaSOF9POGkk1ocRP16Md1x36Xma8sz8h8/vfCUI2OtEIeCqe4Ofes853x4xDoPiFLIT47J5fI/7A==} engines: {node: '>=16'} @@ -1719,6 +1760,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -1913,6 +1958,9 @@ packages: engines: {node: '>=14'} hasBin: true + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} @@ -2263,6 +2311,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.1.1: + resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} + engines: {node: '>=12.20'} + zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} @@ -2919,6 +2971,8 @@ snapshots: aria-query@5.3.2: {} + asynckit@0.4.0: {} + autoprefixer@10.4.20(postcss@8.4.49): dependencies: browserslist: 4.24.4 @@ -2929,6 +2983,14 @@ snapshots: postcss: 8.4.49 postcss-value-parser: 4.2.0 + axios@1.7.9: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} balanced-match@1.0.2: {} @@ -3016,6 +3078,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@4.1.1: {} commondir@1.0.1: {} @@ -3052,6 +3118,8 @@ snapshots: deepmerge@4.3.1: {} + delayed-stream@1.0.0: {} + detect-libc@2.0.3: {} devalue@5.1.1: {} @@ -3348,11 +3416,19 @@ snapshots: flatted@3.3.2: {} + follow-redirects@1.15.9: {} + foreground-child@3.3.0: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.1: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + fraction.js@4.3.7: {} fs-constants@1.0.0: {} @@ -3519,6 +3595,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime@4.0.6: {} mimic-response@3.1.0: {} @@ -3603,6 +3685,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@6.2.0: + dependencies: + yocto-queue: 1.1.1 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -3726,6 +3812,8 @@ snapshots: prettier@3.4.2: {} + proxy-from-env@1.1.0: {} + pump@3.0.2: dependencies: end-of-stream: 1.4.4 @@ -4092,6 +4180,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.1.1: {} + zimmerframe@1.1.2: {} zod@3.24.1: {} diff --git a/src/hooks.client.ts b/src/hooks.client.ts index 10d9e4e..d947c8e 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,6 +1,6 @@ import type { ClientInit } from "@sveltejs/kit"; import { getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB"; -import { prepareFileCache } from "$lib/modules/cache"; +import { prepareFileCache } from "$lib/modules/file"; import { prepareOpfs } from "$lib/modules/opfs"; import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores"; diff --git a/src/lib/modules/cache.ts b/src/lib/modules/file/cache.ts similarity index 100% rename from src/lib/modules/cache.ts rename to src/lib/modules/file/cache.ts diff --git a/src/lib/modules/file/index.ts b/src/lib/modules/file/index.ts new file mode 100644 index 0000000..c4ea9aa --- /dev/null +++ b/src/lib/modules/file/index.ts @@ -0,0 +1,3 @@ +export * from "./cache"; +export * from "./info"; +export * from "./upload"; diff --git a/src/lib/modules/file.ts b/src/lib/modules/file/info.ts similarity index 100% rename from src/lib/modules/file.ts rename to src/lib/modules/file/info.ts diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.ts new file mode 100644 index 0000000..09dffe1 --- /dev/null +++ b/src/lib/modules/file/upload.ts @@ -0,0 +1,221 @@ +import axios from "axios"; +import ExifReader from "exifreader"; +import { limitFunction } from "p-limit"; +import { writable, type Writable } from "svelte/store"; +import { + encodeToBase64, + generateDataKey, + wrapDataKey, + encryptData, + encryptString, + signMessageHmac, +} from "$lib/modules/crypto"; +import type { + DuplicateFileScanRequest, + DuplicateFileScanResponse, + FileUploadRequest, +} from "$lib/server/schemas"; +import { + fileUploadStatusStore, + type MasterKey, + type HmacSecret, + type FileUploadStatus, +} from "$lib/stores"; + +const requestDuplicateFileScan = limitFunction( + async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise) => { + const fileBuffer = await file.arrayBuffer(); + const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret)); + + const res = await axios.post("/api/file/scanDuplicates", { + hskVersion: hmacSecret.version, + contentHmac: fileSigned, + } satisfies DuplicateFileScanRequest); + const { files }: DuplicateFileScanResponse = res.data; + + if (files.length === 0 || (await onDuplicate())) { + return { fileBuffer, fileSigned }; + } else { + return {}; + } + }, + { concurrency: 1 }, +); + +const getFileType = (file: File) => { + if (file.type) return file.type; + if (file.name.endsWith(".heic")) return "image/heic"; + throw new Error("Unknown file type"); +}; + +const extractExifDateTime = (fileBuffer: ArrayBuffer) => { + const exif = ExifReader.load(fileBuffer); + const dateTimeOriginal = exif["DateTimeOriginal"]?.description; + const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description; + if (!dateTimeOriginal) return undefined; + + const [date, time] = dateTimeOriginal.split(" "); + if (!date || !time) return undefined; + + const [year, month, day] = date.split(":").map(Number); + const [hour, minute, second] = time.split(":").map(Number); + if (!year || !month || !day || !hour || !minute || !second) return undefined; + + if (!offsetTimeOriginal) { + // No timezone information.. Assume local timezone + return new Date(year, month - 1, day, hour, minute, second); + } + + const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1; + const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number); + + const utcDate = Date.UTC(year, month - 1, day, hour, minute, second); + const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000; + return new Date(utcDate - offsetMs); +}; + +const encryptFile = limitFunction( + async ( + status: Writable, + file: File, + fileBuffer: ArrayBuffer, + masterKey: MasterKey, + ) => { + status.update((value) => { + value.status = "encrypting"; + return value; + }); + + const fileType = getFileType(file); + + let createdAt; + if (fileType.startsWith("image/")) { + createdAt = extractExifDateTime(fileBuffer); + } + + const { dataKey, dataKeyVersion } = await generateDataKey(); + const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key); + + const fileEncrypted = await encryptData(fileBuffer, dataKey); + const nameEncrypted = await encryptString(file.name, dataKey); + const createdAtEncrypted = + createdAt && (await encryptString(createdAt.getTime().toString(), dataKey)); + const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey); + + status.update((value) => { + value.status = "upload-pending"; + return value; + }); + + return { + dataKeyWrapped, + dataKeyVersion, + fileEncrypted, + fileType, + nameEncrypted, + createdAtEncrypted, + lastModifiedAtEncrypted, + }; + }, + { concurrency: 4 }, +); + +const uploadFileInternal = limitFunction( + async (status: Writable, form: FormData) => { + status.update((value) => { + value.status = "uploading"; + return value; + }); + + await axios.post("/api/file/upload", form, { + onUploadProgress: ({ progress, rate, estimated }) => { + status.update((value) => { + value.progress = progress; + value.rate = rate; + value.estimated = estimated; + return value; + }); + }, + }); + + status.update((value) => { + value.status = "uploaded"; + return value; + }); + }, + { concurrency: 1 }, +); + +export const uploadFile = async ( + file: File, + parentId: "root" | number, + hmacSecret: HmacSecret, + masterKey: MasterKey, + onDuplicate: () => Promise, +) => { + const status = writable({ + name: file.name, + parentId, + status: "encryption-pending", + }); + fileUploadStatusStore.update((value) => { + value.push(status); + return value; + }); + + try { + const { fileBuffer, fileSigned } = await requestDuplicateFileScan( + file, + hmacSecret, + onDuplicate, + ); + if (!fileBuffer || !fileSigned) { + status.update((value) => { + value.status = "canceled"; + return value; + }); + return false; + } + + const { + dataKeyWrapped, + dataKeyVersion, + fileEncrypted, + fileType, + nameEncrypted, + createdAtEncrypted, + lastModifiedAtEncrypted, + } = await encryptFile(status, file, fileBuffer, masterKey); + + const form = new FormData(); + form.set( + "metadata", + JSON.stringify({ + parentId, + mekVersion: masterKey.version, + dek: dataKeyWrapped, + dekVersion: dataKeyVersion.toISOString(), + hskVersion: hmacSecret.version, + contentHmac: fileSigned, + contentType: fileType, + contentIv: fileEncrypted.iv, + name: nameEncrypted.ciphertext, + nameIv: nameEncrypted.iv, + createdAt: createdAtEncrypted?.ciphertext, + createdAtIv: createdAtEncrypted?.iv, + lastModifiedAt: lastModifiedAtEncrypted.ciphertext, + lastModifiedAtIv: lastModifiedAtEncrypted.iv, + } as FileUploadRequest), + ); + form.set("content", new Blob([fileEncrypted.ciphertext])); + + await uploadFileInternal(status, form); + return true; + } catch (e) { + status.update((value) => { + value.status = "error"; + return value; + }); + throw e; + } +}; diff --git a/src/lib/stores/file.ts b/src/lib/stores/file.ts index 2debd57..f7bf8b4 100644 --- a/src/lib/stores/file.ts +++ b/src/lib/stores/file.ts @@ -1,4 +1,4 @@ -import type { Writable } from "svelte/store"; +import { writable, type Writable } from "svelte/store"; export type DirectoryInfo = | { @@ -29,6 +29,24 @@ export interface FileInfo { lastModifiedAt: Date; } +export interface FileUploadStatus { + name: string; + parentId: "root" | number; + status: + | "encryption-pending" + | "encrypting" + | "upload-pending" + | "uploading" + | "uploaded" + | "canceled" + | "error"; + progress?: number; + rate?: number; + estimated?: number; +} + export const directoryInfoStore = new Map<"root" | number, Writable>(); export const fileInfoStore = new Map>(); + +export const fileUploadStatusStore = writable[]>([]); diff --git a/src/routes/(fullscreen)/file/[id]/service.ts b/src/routes/(fullscreen)/file/[id]/service.ts index dfdb92b..55a5806 100644 --- a/src/routes/(fullscreen)/file/[id]/service.ts +++ b/src/routes/(fullscreen)/file/[id]/service.ts @@ -1,4 +1,4 @@ -import { getFileCache, storeFileCache } from "$lib/modules/cache"; +import { getFileCache, storeFileCache } from "$lib/modules/file"; import { decryptData } from "$lib/modules/crypto"; export const requestFileDownload = async ( diff --git a/src/routes/(fullscreen)/setting/cache/+page.svelte b/src/routes/(fullscreen)/setting/cache/+page.svelte index 6f3770e..fac9f31 100644 --- a/src/routes/(fullscreen)/setting/cache/+page.svelte +++ b/src/routes/(fullscreen)/setting/cache/+page.svelte @@ -3,8 +3,7 @@ import type { Writable } from "svelte/store"; import { TopBar } from "$lib/components"; import type { FileCacheIndex } from "$lib/indexedDB"; - import { getFileCacheIndex } from "$lib/modules/cache"; - import { getFileInfo } from "$lib/modules/file"; + import { getFileCacheIndex, getFileInfo } from "$lib/modules/file"; import { masterKeyStore, type FileInfo } from "$lib/stores"; import File from "./File.svelte"; import { formatFileSize, deleteFileCache as doDeleteFileCache } from "./service"; diff --git a/src/routes/(fullscreen)/setting/cache/service.ts b/src/routes/(fullscreen)/setting/cache/service.ts index a3fb37b..a8fc1c6 100644 --- a/src/routes/(fullscreen)/setting/cache/service.ts +++ b/src/routes/(fullscreen)/setting/cache/service.ts @@ -1,4 +1,4 @@ -import { deleteFileCache as doDeleteFileCache } from "$lib/modules/cache"; +import { deleteFileCache as doDeleteFileCache } from "$lib/modules/file"; export { formatDate, formatFileSize } from "$lib/modules/util"; diff --git a/src/routes/(main)/directory/[[id]]/+page.svelte b/src/routes/(main)/directory/[[id]]/+page.svelte index c5e9f8b..ee7e86e 100644 --- a/src/routes/(main)/directory/[[id]]/+page.svelte +++ b/src/routes/(main)/directory/[[id]]/+page.svelte @@ -16,7 +16,6 @@ import { requestHmacSecretDownload, requestDirectoryCreation, - requestDuplicateFileScan, requestFileUpload, requestDirectoryEntryRename, requestDirectoryEntryDeletion, @@ -25,17 +24,11 @@ import IconAdd from "~icons/material-symbols/add"; - interface LoadedFile { - file: File; - fileBuffer: ArrayBuffer; - fileSigned: string; - } - let { data } = $props(); let info: Writable | undefined = $state(); let fileInput: HTMLInputElement | undefined = $state(); - let loadedFile: LoadedFile | undefined = $state(); + let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state(); let selectedEntry: SelectedDirectoryEntry | undefined = $state(); let isCreateBottomSheetOpen = $state(false); @@ -52,43 +45,32 @@ info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME }; - const uploadFile = (loadedFile: LoadedFile) => { - requestFileUpload( - loadedFile.file, - loadedFile.fileBuffer, - loadedFile.fileSigned, - data.id, - $masterKeyStore?.get(1)!, - $hmacSecretStore?.get(1)!, - ) - .then(() => { + const uploadFile = () => { + const file = fileInput?.files?.[0]; + if (!file) return; + + fileInput!.value = ""; + + requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => { + return new Promise((resolve) => { + resolveForDuplicateFileModal = resolve; + isDuplicateFileModalOpen = true; + }); + }) + .then((res) => { + if (!res) return; + // TODO: FIXME info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); window.alert("파일이 업로드되었어요."); }) .catch((e: Error) => { // TODO: FIXME + console.error(e); window.alert(`파일 업로드에 실패했어요.\n${e.message}`); }); }; - const loadAndUploadFile = async () => { - const file = fileInput?.files?.[0]; - if (!file) return; - - 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 () => { if (!$hmacSecretStore && !(await requestHmacSecretDownload($masterKeyStore?.get(1)?.key!))) { throw new Error("Failed to download hmac secrets"); @@ -104,7 +86,7 @@ 파일 - +
{#if data.id !== "root"} @@ -148,13 +130,14 @@ { + resolveForDuplicateFileModal?.(false); + resolveForDuplicateFileModal = undefined; isDuplicateFileModalOpen = false; - loadedFile = undefined; }} onDuplicateClick={() => { - uploadFile(loadedFile!); + resolveForDuplicateFileModal?.(true); + resolveForDuplicateFileModal = undefined; isDuplicateFileModalOpen = false; - loadedFile = undefined; }} /> diff --git a/src/routes/(main)/directory/[[id]]/DuplicateFileModal.svelte b/src/routes/(main)/directory/[[id]]/DuplicateFileModal.svelte index 583fda8..277d8ce 100644 --- a/src/routes/(main)/directory/[[id]]/DuplicateFileModal.svelte +++ b/src/routes/(main)/directory/[[id]]/DuplicateFileModal.svelte @@ -18,14 +18,7 @@

그래도 업로드할까요?

- +
diff --git a/src/routes/(main)/directory/[[id]]/service.ts b/src/routes/(main)/directory/[[id]]/service.ts index 00d28ab..2f6db4d 100644 --- a/src/routes/(main)/directory/[[id]]/service.ts +++ b/src/routes/(main)/directory/[[id]]/service.ts @@ -1,24 +1,12 @@ -import ExifReader from "exifreader"; import { callGetApi, callPostApi } from "$lib/hooks"; import { storeHmacSecrets } from "$lib/indexedDB"; -import { deleteFileCache } from "$lib/modules/cache"; -import { - encodeToBase64, - generateDataKey, - wrapDataKey, - unwrapHmacSecret, - encryptData, - encryptString, - signMessageHmac, -} from "$lib/modules/crypto"; +import { deleteFileCache, uploadFile } from "$lib/modules/file"; +import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto"; import type { DirectoryRenameRequest, DirectoryCreateRequest, FileRenameRequest, - FileUploadRequest, HmacSecretListResponse, - DuplicateFileScanRequest, - DuplicateFileScanResponse, DirectoryDeleteResponse, } from "$lib/server/schemas"; import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores"; @@ -68,106 +56,14 @@ export const requestDirectoryCreation = async ( }); }; -export const requestDuplicateFileScan = async (file: File, hmacSecret: HmacSecret) => { - const fileBuffer = await file.arrayBuffer(); - const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret)); - const res = await callPostApi("/api/file/scanDuplicates", { - hskVersion: hmacSecret.version, - contentHmac: fileSigned, - }); - if (!res.ok) return null; - - const { files }: DuplicateFileScanResponse = await res.json(); - return { - fileBuffer, - fileSigned, - isDuplicate: files.length > 0, - }; -}; - -const extractExifDateTime = (fileBuffer: ArrayBuffer) => { - const exif = ExifReader.load(fileBuffer); - const dateTimeOriginal = exif["DateTimeOriginal"]?.description; - const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description; - if (!dateTimeOriginal) return undefined; - - const [date, time] = dateTimeOriginal.split(" "); - if (!date || !time) return undefined; - - const [year, month, day] = date.split(":").map(Number); - const [hour, minute, second] = time.split(":").map(Number); - if (!year || !month || !day || !hour || !minute || !second) return undefined; - - if (!offsetTimeOriginal) { - // No timezone information -> Local timezone - return new Date(year, month - 1, day, hour, minute, second); - } - - const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1; - const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number); - - const utcDate = Date.UTC(year, month - 1, day, hour, minute, second); - const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000; - return new Date(utcDate - offsetMs); -}; - export const requestFileUpload = async ( file: File, - fileBuffer: ArrayBuffer, - fileSigned: string, parentId: "root" | number, - masterKey: MasterKey, hmacSecret: HmacSecret, + masterKey: MasterKey, + onDuplicate: () => Promise, ) => { - let createdAt = undefined; - if (file.type.startsWith("image/")) { - createdAt = extractExifDateTime(fileBuffer); - } - - const { dataKey, dataKeyVersion } = await generateDataKey(); - const fileEncrypted = await encryptData(fileBuffer, dataKey); - const nameEncrypted = await encryptString(file.name, dataKey); - const createdAtEncrypted = - createdAt && (await encryptString(createdAt.getTime().toString(), dataKey)); - const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey); - - const form = new FormData(); - form.set( - "metadata", - JSON.stringify({ - parentId, - mekVersion: masterKey.version, - dek: await wrapDataKey(dataKey, masterKey.key), - dekVersion: dataKeyVersion.toISOString(), - hskVersion: hmacSecret.version, - contentHmac: fileSigned, - contentType: file.type, - contentIv: fileEncrypted.iv, - name: nameEncrypted.ciphertext, - nameIv: nameEncrypted.iv, - createdAt: createdAtEncrypted?.ciphertext, - createdAtIv: createdAtEncrypted?.iv, - lastModifiedAt: lastModifiedAtEncrypted.ciphertext, - lastModifiedAtIv: lastModifiedAtEncrypted.iv, - } satisfies FileUploadRequest), - ); - form.set("content", new Blob([fileEncrypted.ciphertext])); - - return new Promise((resolve, reject) => { - // TODO: Progress, Scheduling, ... - - const xhr = new XMLHttpRequest(); - xhr.addEventListener("load", () => { - if (xhr.status === 200) { - resolve(); - } else { - reject(new Error(xhr.responseText)); - } - }); - - xhr.open("POST", "/api/file/upload"); - xhr.send(form); - }); + return await uploadFile(file, parentId, hmacSecret, masterKey, onDuplicate); }; export const requestDirectoryEntryRename = async (