파일 업로드 스케쥴링 구현

암호화는 동시에 최대 4개까지, 업로드는 1개까지 가능하도록 설정했습니다.
This commit is contained in:
static
2025-01-16 02:33:00 +09:00
parent 366f657113
commit 937c4e2453
14 changed files with 367 additions and 162 deletions

View File

@@ -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",

90
pnpm-lock.yaml generated
View File

@@ -63,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
@@ -96,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
@@ -975,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}
@@ -982,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'}
@@ -1063,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'}
@@ -1117,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'}
@@ -1412,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==}
@@ -1619,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'}
@@ -1719,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'}
@@ -1913,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==}
@@ -2263,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==}
@@ -2919,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
@@ -2929,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: {}
@@ -3016,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: {}
@@ -3052,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: {}
@@ -3348,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: {}
@@ -3519,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: {}
@@ -3603,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
@@ -3726,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
@@ -4092,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: {}

View File

@@ -1,6 +1,6 @@
import type { ClientInit } from "@sveltejs/kit"; import type { ClientInit } from "@sveltejs/kit";
import { getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB"; 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 { prepareOpfs } from "$lib/modules/opfs";
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores"; import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";

View File

@@ -0,0 +1,3 @@
export * from "./cache";
export * from "./info";
export * from "./upload";

View File

@@ -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<boolean>) => {
const fileBuffer = await file.arrayBuffer();
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
const res = await axios.post("/api/file/scanDuplicates", {
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
} satisfies DuplicateFileScanRequest);
const { files }: DuplicateFileScanResponse = res.data;
if (files.length === 0 || (await onDuplicate())) {
return { fileBuffer, fileSigned };
} else {
return {};
}
},
{ concurrency: 1 },
);
const getFileType = (file: File) => {
if (file.type) return file.type;
if (file.name.endsWith(".heic")) return "image/heic";
throw new Error("Unknown file type");
};
const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
const exif = ExifReader.load(fileBuffer);
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
if (!dateTimeOriginal) return undefined;
const [date, time] = dateTimeOriginal.split(" ");
if (!date || !time) return undefined;
const [year, month, day] = date.split(":").map(Number);
const [hour, minute, second] = time.split(":").map(Number);
if (!year || !month || !day || !hour || !minute || !second) return undefined;
if (!offsetTimeOriginal) {
// No timezone information.. Assume local timezone
return new Date(year, month - 1, day, hour, minute, second);
}
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
return new Date(utcDate - offsetMs);
};
const encryptFile = limitFunction(
async (
status: Writable<FileUploadStatus>,
file: File,
fileBuffer: ArrayBuffer,
masterKey: MasterKey,
) => {
status.update((value) => {
value.status = "encrypting";
return value;
});
const fileType = getFileType(file);
let createdAt;
if (fileType.startsWith("image/")) {
createdAt = extractExifDateTime(fileBuffer);
}
const { dataKey, dataKeyVersion } = await generateDataKey();
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
const fileEncrypted = await encryptData(fileBuffer, dataKey);
const nameEncrypted = await encryptString(file.name, dataKey);
const createdAtEncrypted =
createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
status.update((value) => {
value.status = "upload-pending";
return value;
});
return {
dataKeyWrapped,
dataKeyVersion,
fileEncrypted,
fileType,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
};
},
{ concurrency: 4 },
);
const uploadFileInternal = 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;
});
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;
}
};

View File

@@ -1,4 +1,4 @@
import type { Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
export type DirectoryInfo = export type DirectoryInfo =
| { | {
@@ -29,6 +29,24 @@ export interface FileInfo {
lastModifiedAt: Date; 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<DirectoryInfo | null>>(); export const directoryInfoStore = new Map<"root" | number, Writable<DirectoryInfo | null>>();
export const fileInfoStore = new Map<number, Writable<FileInfo | null>>(); export const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
export const fileUploadStatusStore = writable<Writable<FileUploadStatus>[]>([]);

View File

@@ -1,4 +1,4 @@
import { getFileCache, storeFileCache } from "$lib/modules/cache"; import { getFileCache, storeFileCache } from "$lib/modules/file";
import { decryptData } from "$lib/modules/crypto"; import { decryptData } from "$lib/modules/crypto";
export const requestFileDownload = async ( export const requestFileDownload = async (

View File

@@ -3,8 +3,7 @@
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { TopBar } from "$lib/components"; import { TopBar } from "$lib/components";
import type { FileCacheIndex } from "$lib/indexedDB"; import type { FileCacheIndex } from "$lib/indexedDB";
import { getFileCacheIndex } from "$lib/modules/cache"; import { getFileCacheIndex, getFileInfo } from "$lib/modules/file";
import { getFileInfo } from "$lib/modules/file";
import { masterKeyStore, type FileInfo } from "$lib/stores"; import { masterKeyStore, type FileInfo } from "$lib/stores";
import File from "./File.svelte"; import File from "./File.svelte";
import { formatFileSize, deleteFileCache as doDeleteFileCache } from "./service"; import { formatFileSize, deleteFileCache as doDeleteFileCache } from "./service";

View File

@@ -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"; export { formatDate, formatFileSize } from "$lib/modules/util";

View File

@@ -16,7 +16,6 @@
import { import {
requestHmacSecretDownload, requestHmacSecretDownload,
requestDirectoryCreation, requestDirectoryCreation,
requestDuplicateFileScan,
requestFileUpload, requestFileUpload,
requestDirectoryEntryRename, requestDirectoryEntryRename,
requestDirectoryEntryDeletion, requestDirectoryEntryDeletion,
@@ -25,17 +24,11 @@
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 selectedEntry: SelectedDirectoryEntry | undefined = $state(); let selectedEntry: SelectedDirectoryEntry | undefined = $state();
let isCreateBottomSheetOpen = $state(false); let isCreateBottomSheetOpen = $state(false);
@@ -52,43 +45,32 @@
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 file = fileInput?.files?.[0];
loadedFile.file, if (!file) return;
loadedFile.fileBuffer,
loadedFile.fileSigned, fileInput!.value = "";
data.id,
$masterKeyStore?.get(1)!, requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => {
$hmacSecretStore?.get(1)!, return new Promise((resolve) => {
) resolveForDuplicateFileModal = resolve;
.then(() => { isDuplicateFileModalOpen = true;
});
})
.then((res) => {
if (!res) return;
// TODO: FIXME // TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
window.alert("파일이 업로드되었어요."); window.alert("파일이 업로드되었어요.");
}) })
.catch((e: Error) => { .catch((e: Error) => {
// TODO: FIXME // TODO: FIXME
console.error(e);
window.alert(`파일 업로드에 실패했어요.\n${e.message}`); 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 () => { onMount(async () => {
if (!$hmacSecretStore && !(await requestHmacSecretDownload($masterKeyStore?.get(1)?.key!))) { if (!$hmacSecretStore && !(await requestHmacSecretDownload($masterKeyStore?.get(1)?.key!))) {
throw new Error("Failed to download hmac secrets"); throw new Error("Failed to download hmac secrets");
@@ -104,7 +86,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" 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"}
@@ -148,13 +130,14 @@
<DuplicateFileModal <DuplicateFileModal
bind:isOpen={isDuplicateFileModalOpen} bind:isOpen={isDuplicateFileModalOpen}
onclose={() => { onclose={() => {
resolveForDuplicateFileModal?.(false);
resolveForDuplicateFileModal = undefined;
isDuplicateFileModalOpen = false; isDuplicateFileModalOpen = false;
loadedFile = undefined;
}} }}
onDuplicateClick={() => { onDuplicateClick={() => {
uploadFile(loadedFile!); resolveForDuplicateFileModal?.(true);
resolveForDuplicateFileModal = undefined;
isDuplicateFileModalOpen = false; isDuplicateFileModalOpen = false;
loadedFile = undefined;
}} }}
/> />

View File

@@ -18,14 +18,7 @@
<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>

View File

@@ -1,24 +1,12 @@
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 { deleteFileCache } from "$lib/modules/cache"; import { deleteFileCache, uploadFile } from "$lib/modules/file";
import { import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
encodeToBase64,
generateDataKey,
wrapDataKey,
unwrapHmacSecret,
encryptData,
encryptString,
signMessageHmac,
} from "$lib/modules/crypto";
import type { import type {
DirectoryRenameRequest, DirectoryRenameRequest,
DirectoryCreateRequest, DirectoryCreateRequest,
FileRenameRequest, FileRenameRequest,
FileUploadRequest,
HmacSecretListResponse, HmacSecretListResponse,
DuplicateFileScanRequest,
DuplicateFileScanResponse,
DirectoryDeleteResponse, DirectoryDeleteResponse,
} 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";
@@ -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<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 (