업로드할 때에도 스트리밍 방식으로 처리하도록 변경

This commit is contained in:
static
2026-01-11 13:19:54 +09:00
parent 1efcdd68f1
commit 3628e6d21a
9 changed files with 308 additions and 62 deletions

View File

@@ -19,6 +19,7 @@
"@eslint/compat": "^2.0.0", "@eslint/compat": "^2.0.0",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@iconify-json/material-symbols": "^1.2.50", "@iconify-json/material-symbols": "^1.2.50",
"@noble/hashes": "^2.0.1",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.2", "@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",

9
pnpm-lock.yaml generated
View File

@@ -48,6 +48,9 @@ importers:
'@iconify-json/material-symbols': '@iconify-json/material-symbols':
specifier: ^1.2.50 specifier: ^1.2.50
version: 1.2.50 version: 1.2.50
'@noble/hashes':
specifier: ^2.0.1
version: 2.0.1
'@sveltejs/adapter-node': '@sveltejs/adapter-node':
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.4.0(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@1.21.7)(yaml@2.8.0)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@1.21.7)(yaml@2.8.0))) version: 5.4.0(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@1.21.7)(yaml@2.8.0)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@1.21.7)(yaml@2.8.0)))
@@ -414,6 +417,10 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@noble/hashes@2.0.1':
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2217,6 +2224,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@noble/hashes@2.0.1': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5

View File

@@ -70,12 +70,12 @@ export const storeMasterKeys = async (keys: MasterKey[]) => {
}; };
export const getHmacSecrets = async () => { export const getHmacSecrets = async () => {
return await keyStore.hmacSecret.toArray(); return (await keyStore.hmacSecret.toArray()).filter(({ secret }) => secret.extractable);
}; };
export const storeHmacSecrets = async (secrets: HmacSecret[]) => { export const storeHmacSecrets = async (secrets: HmacSecret[]) => {
if (secrets.some(({ secret }) => secret.extractable)) { if (secrets.some(({ secret }) => !secret.extractable)) {
throw new Error("Hmac secrets must be nonextractable"); throw new Error("Hmac secrets must be extractable");
} }
await keyStore.hmacSecret.bulkPut(secrets); await keyStore.hmacSecret.bulkPut(secrets);
}; };

View File

@@ -77,7 +77,7 @@ export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: Cry
name: "HMAC", name: "HMAC",
hash: "SHA-256", hash: "SHA-256",
} satisfies HmacImportParams, } satisfies HmacImportParams,
false, // Nonextractable true, // Extractable
["sign", "verify"], ["sign", "verify"],
), ),
}; };

View File

@@ -1,7 +1,20 @@
import { hmac } from "@noble/hashes/hmac.js";
import { sha256 } from "@noble/hashes/sha2.js";
export const digestMessage = async (message: BufferSource) => { export const digestMessage = async (message: BufferSource) => {
return await crypto.subtle.digest("SHA-256", message); return await crypto.subtle.digest("SHA-256", message);
}; };
export const createStreamingHmac = async (hmacSecret: CryptoKey) => {
const keyBytes = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret));
const h = hmac.create(sha256, keyBytes);
return {
update: (data: Uint8Array) => h.update(data),
digest: () => h.digest(),
};
};
export const generateHmacSecret = async () => { export const generateHmacSecret = async () => {
return { return {
hmacSecret: await crypto.subtle.generateKey( hmacSecret: await crypto.subtle.generateKey(

View File

@@ -9,8 +9,8 @@ export const decodeString = (data: ArrayBuffer) => {
return textDecoder.decode(data); return textDecoder.decode(data);
}; };
export const encodeToBase64 = (data: ArrayBuffer) => { export const encodeToBase64 = (data: ArrayBuffer | Uint8Array) => {
return btoa(String.fromCharCode(...new Uint8Array(data))); return btoa(String.fromCharCode(...(data instanceof ArrayBuffer ? new Uint8Array(data) : data)));
}; };
export const decodeFromBase64 = (data: string) => { export const decodeFromBase64 = (data: string) => {

View File

@@ -1,6 +1,6 @@
import axios from "axios"; import axios from "axios";
import ExifReader from "exifreader"; import ExifReader from "exifreader";
import { limitFunction } from "p-limit"; import pLimit, { limitFunction } from "p-limit";
import { CHUNK_SIZE } from "$lib/constants"; import { CHUNK_SIZE } from "$lib/constants";
import { import {
encodeToBase64, encodeToBase64,
@@ -11,9 +11,10 @@ import {
encryptChunk, encryptChunk,
digestMessage, digestMessage,
signMessageHmac, signMessageHmac,
createStreamingHmac,
} from "$lib/modules/crypto"; } from "$lib/modules/crypto";
import { Scheduler } from "$lib/modules/scheduler"; import { Scheduler } from "$lib/modules/scheduler";
import { generateThumbnail } from "$lib/modules/thumbnail"; import { generateThumbnail, generateThumbnailFromFile } from "$lib/modules/thumbnail";
import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
import type { MasterKey, HmacSecret } from "$lib/stores"; import type { MasterKey, HmacSecret } from "$lib/stores";
import { trpc } from "$trpc/client"; import { trpc } from "$trpc/client";
@@ -41,7 +42,7 @@ export type LiveFileUploadState = FileUploadState & {
}; };
const scheduler = new Scheduler< const scheduler = new Scheduler<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined { fileId: number; fileBuffer?: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
>(); >();
let uploadingFiles: FileUploadState[] = $state([]); let uploadingFiles: FileUploadState[] = $state([]);
@@ -77,6 +78,33 @@ const requestDuplicateFileScan = limitFunction(
{ concurrency: 1 }, { concurrency: 1 },
); );
const isImageFile = (fileType: string) => fileType.startsWith("image/");
const requestDuplicateFileScanStreaming = limitFunction(
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
const hmacStream = await createStreamingHmac(hmacSecret.secret);
const reader = file.stream().getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
hmacStream.update(value);
}
const fileSigned = encodeToBase64(hmacStream.digest());
const files = await trpc().file.listByHash.query({
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
});
if (files.length === 0 || (await onDuplicate())) {
return { fileSigned };
} else {
return {};
}
},
{ concurrency: 1 },
);
const getFileType = (file: File) => { const getFileType = (file: File) => {
if (file.type) return file.type; if (file.type) return file.type;
if (file.name.endsWith(".heic")) return "image/heic"; if (file.name.endsWith(".heic")) return "image/heic";
@@ -235,6 +263,148 @@ const requestFileUpload = limitFunction(
{ concurrency: 1 }, { concurrency: 1 },
); );
const uploadFileStreaming = async (
state: FileUploadState,
file: File,
masterKey: MasterKey,
hmacSecret: HmacSecret,
fileSigned: string,
parentId: DirectoryId,
) => {
state.status = "uploading";
const fileType = getFileType(file);
const { dataKey, dataKeyVersion } = await generateDataKey();
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
const nameEncrypted = await encryptString(file.name, dataKey);
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
// Calculate total chunks for metadata
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const metadata = {
chunks: totalChunks,
parent: parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion,
hskVersion: hmacSecret.version,
contentType: fileType,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
};
const { uploadId } = await trpc().file.startUpload.mutate(metadata);
// Stream file, encrypt, and upload with concurrency limit
const reader = file.stream().getReader();
const limit = pLimit(4);
let buffer = new Uint8Array(0);
let chunkIndex = 0;
const uploadPromises: Promise<void>[] = [];
const totalBytes = file.size;
let uploadedBytes = 0;
const startTime = Date.now();
const uploadChunk = async (
index: number,
encryptedChunk: ArrayBuffer,
chunkHash: string,
originalChunkSize: number,
) => {
const response = await fetch(`/api/file/upload/${uploadId}/chunks/${index}`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"Content-Digest": `sha-256=:${chunkHash}:`,
},
body: encryptedChunk,
});
if (!response.ok) {
throw new Error(`Chunk upload failed: ${response.status} ${response.statusText}`);
}
// Update progress after upload completes
uploadedBytes += originalChunkSize;
const elapsed = (Date.now() - startTime) / 1000;
const rate = uploadedBytes / elapsed;
const remaining = totalBytes - uploadedBytes;
const estimated = rate > 0 ? remaining / rate : undefined;
state.progress = uploadedBytes / totalBytes;
state.rate = rate;
state.estimated = estimated;
};
while (true) {
const { done, value } = await reader.read();
if (done && buffer.length === 0) break;
if (value) {
const newBuffer = new Uint8Array(buffer.length + value.length);
newBuffer.set(buffer);
newBuffer.set(value, buffer.length);
buffer = newBuffer;
}
while (buffer.length >= CHUNK_SIZE || (done && buffer.length > 0)) {
const chunkSize = Math.min(CHUNK_SIZE, buffer.length);
const chunk = buffer.slice(0, chunkSize);
buffer = buffer.slice(chunkSize);
const encryptedChunk = await encryptChunk(chunk.buffer.slice(0, chunk.byteLength), dataKey);
const chunkHash = encodeToBase64(await digestMessage(encryptedChunk));
const currentIndex = chunkIndex++;
uploadPromises.push(
limit(() => uploadChunk(currentIndex, encryptedChunk, chunkHash, chunkSize)),
);
}
if (done) break;
}
await Promise.all(uploadPromises);
const { file: fileId } = await trpc().file.completeUpload.mutate({
uploadId,
contentHmac: fileSigned,
});
// Generate and upload thumbnail for video files
if (fileType.startsWith("video/")) {
try {
const thumbnail = await generateThumbnailFromFile(file);
if (thumbnail) {
const thumbnailBuffer = await thumbnail.arrayBuffer();
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
const thumbnailForm = new FormData();
thumbnailForm.set(
"metadata",
JSON.stringify({
dekVersion: dataKeyVersion.toISOString(),
contentIv: encodeToBase64(thumbnailEncrypted.iv),
} satisfies FileThumbnailUploadRequest),
);
thumbnailForm.set("content", new Blob([thumbnailEncrypted.ciphertext]));
await axios.post(`/api/file/${fileId}/thumbnail/upload`, thumbnailForm);
}
} catch (e) {
// Thumbnail upload failure is not critical
console.error(e);
}
}
state.status = "uploaded";
return { fileId };
};
export const uploadFile = async ( export const uploadFile = async (
file: File, file: File,
parentId: "root" | number, parentId: "root" | number,
@@ -249,69 +419,103 @@ export const uploadFile = async (
}); });
const state = uploadingFiles.at(-1)!; const state = uploadingFiles.at(-1)!;
const fileType = getFileType(file);
// Image files: use buffer-based approach (need EXIF + thumbnail)
if (isImageFile(fileType)) {
return await scheduler.schedule(file.size, async () => {
state.status = "encryption-pending";
try {
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
file,
hmacSecret,
onDuplicate,
);
if (!fileBuffer || !fileSigned) {
state.status = "canceled";
uploadingFiles = uploadingFiles.filter((file) => file !== state);
return undefined;
}
const {
dataKeyWrapped,
dataKeyVersion,
fileType,
chunksEncrypted,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail,
} = await encryptFile(state, file, fileBuffer, masterKey);
const metadata = {
chunks: chunksEncrypted.length,
parent: parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion,
hskVersion: hmacSecret.version,
contentType: fileType,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
};
let thumbnailForm = null;
if (thumbnail) {
thumbnailForm = new FormData();
thumbnailForm.set(
"metadata",
JSON.stringify({
dekVersion: dataKeyVersion.toISOString(),
contentIv: encodeToBase64(thumbnail.iv),
} satisfies FileThumbnailUploadRequest),
);
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
}
const { fileId } = await requestFileUpload(
state,
metadata,
chunksEncrypted,
fileSigned,
thumbnailForm,
);
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
} catch (e) {
state.status = "error";
throw e;
}
});
}
// Video and other files: use streaming approach
return await scheduler.schedule(file.size, async () => { return await scheduler.schedule(file.size, async () => {
state.status = "encryption-pending"; state.status = "encryption-pending";
try { try {
const { fileBuffer, fileSigned } = await requestDuplicateFileScan( // 1st pass: streaming HMAC for duplicate check
file, const { fileSigned } = await requestDuplicateFileScanStreaming(file, hmacSecret, onDuplicate);
hmacSecret, if (!fileSigned) {
onDuplicate,
);
if (!fileBuffer || !fileSigned) {
state.status = "canceled"; state.status = "canceled";
uploadingFiles = uploadingFiles.filter((file) => file !== state); uploadingFiles = uploadingFiles.filter((f) => f !== state);
return undefined; return undefined;
} }
const { // 2nd pass: streaming encrypt + upload
dataKeyWrapped, const { fileId } = await uploadFileStreaming(
dataKeyVersion,
fileType,
chunksEncrypted,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail,
} = await encryptFile(state, file, fileBuffer, masterKey);
const metadata = {
chunks: chunksEncrypted.length,
parent: parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion,
hskVersion: hmacSecret.version,
contentType: fileType,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
};
let thumbnailForm = null;
if (thumbnail) {
thumbnailForm = new FormData();
thumbnailForm.set(
"metadata",
JSON.stringify({
dekVersion: dataKeyVersion.toISOString(),
contentIv: encodeToBase64(thumbnail.iv),
} satisfies FileThumbnailUploadRequest),
);
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
}
const { fileId } = await requestFileUpload(
state, state,
metadata, file,
chunksEncrypted, masterKey,
hmacSecret,
fileSigned, fileSigned,
thumbnailForm, parentId,
); );
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; return { fileId, fileBuffer: undefined, thumbnailBuffer: undefined };
} catch (e) { } catch (e) {
state.status = "error"; state.status = "error";
throw e; throw e;

View File

@@ -125,3 +125,20 @@ export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: strin
export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => { export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => {
return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`; return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`;
}; };
export const generateThumbnailFromFile = async (file: File) => {
const fileType = file.type || (file.name.endsWith(".heic") ? "image/heic" : "");
if (!fileType.startsWith("video/")) return null;
let url;
try {
url = URL.createObjectURL(file);
return await generateVideoThumbnail(url);
} catch {
return null;
} finally {
if (url) {
URL.revokeObjectURL(url);
}
}
};

View File

@@ -88,7 +88,9 @@ export const requestFileUpload = async (
const res = await uploadFile(file, parentId, hmacSecret, masterKey, onDuplicate); const res = await uploadFile(file, parentId, hmacSecret, masterKey, onDuplicate);
if (!res) return false; if (!res) return false;
storeFileCache(res.fileId, res.fileBuffer); // Intended if (res.fileBuffer) {
storeFileCache(res.fileId, res.fileBuffer); // Intended
}
if (res.thumbnailBuffer) { if (res.thumbnailBuffer) {
storeFileThumbnailCache(res.fileId, res.thumbnailBuffer); // Intended storeFileThumbnailCache(res.fileId, res.thumbnailBuffer); // Intended
} }