From a4912c8952cf6b706571a767e7f5b297fe756d84 Mon Sep 17 00:00:00 2001 From: static Date: Mon, 12 Jan 2026 20:50:19 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=86=8C=ED=95=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yaml | 1 + src/hooks.server.ts | 2 +- src/lib/modules/crypto/sha.ts | 6 +- src/lib/modules/file/download.svelte.ts | 4 +- src/lib/modules/file/thumbnail.ts | 9 +- src/lib/modules/file/upload.svelte.ts | 5 +- src/lib/modules/upload.ts | 43 ++++--- src/lib/server/db/file.ts | 13 +- .../migrations/1768062380-AddChunkedUpload.ts | 4 +- src/lib/server/db/schema/upload.ts | 3 - src/lib/server/db/upload.ts | 67 ++-------- .../settings/migration/service.svelte.ts | 17 ++- .../settings/thumbnail/File.svelte | 1 - .../settings/thumbnail/service.ts | 3 +- .../api/upload/[id]/chunks/[index]/+server.ts | 2 +- src/service-worker/constants.ts | 1 + src/service-worker/handlers/decryptFile.ts | 7 +- src/service-worker/index.ts | 2 +- src/service-worker/modules/constants.ts | 1 - src/service-worker/modules/crypto.ts | 2 +- src/trpc/routers/upload.ts | 119 +++++++++--------- 21 files changed, 132 insertions(+), 180 deletions(-) create mode 100644 src/service-worker/constants.ts delete mode 100644 src/service-worker/modules/constants.ts diff --git a/docker-compose.yaml b/docker-compose.yaml index a624d9f..3544f14 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,6 +9,7 @@ services: volumes: - ./data/library:/app/data/library - ./data/thumbnails:/app/data/thumbnails + - ./data/uploads:/app/data/uploads environment: # ArkVault - DATABASE_HOST=database diff --git a/src/hooks.server.ts b/src/hooks.server.ts index b816f7f..c670968 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -7,8 +7,8 @@ import { cleanupExpiredSessions, cleanupExpiredSessionUpgradeChallenges, } from "$lib/server/db/session"; -import { cleanupExpiredUploadSessions } from "$lib/server/services/upload"; import { authenticate, setAgentInfo } from "$lib/server/middlewares"; +import { cleanupExpiredUploadSessions } from "$lib/server/services/upload"; export const init: ServerInit = async () => { await migrateDB(); diff --git a/src/lib/modules/crypto/sha.ts b/src/lib/modules/crypto/sha.ts index 5e9e3fa..286e6f2 100644 --- a/src/lib/modules/crypto/sha.ts +++ b/src/lib/modules/crypto/sha.ts @@ -19,13 +19,13 @@ export const generateHmacSecret = async () => { }; export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => { - const worker = new HmacWorker(); const stream = message.stream(); const hmacSecretRaw = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret)); + const worker = new HmacWorker(); return new Promise((resolve, reject) => { - worker.onmessage = (event: MessageEvent) => { - resolve(event.data.result); + worker.onmessage = ({ data }: MessageEvent) => { + resolve(data.result); worker.terminate(); }; diff --git a/src/lib/modules/file/download.svelte.ts b/src/lib/modules/file/download.svelte.ts index d438e3f..88f1e9e 100644 --- a/src/lib/modules/file/download.svelte.ts +++ b/src/lib/modules/file/download.svelte.ts @@ -1,6 +1,6 @@ import axios from "axios"; import { limitFunction } from "p-limit"; -import { CHUNK_SIZE, ENCRYPTION_OVERHEAD } from "$lib/constants"; +import { ENCRYPTED_CHUNK_SIZE } from "$lib/constants"; import { decryptChunk, concatenateBuffers } from "$lib/modules/crypto"; export interface FileDownloadState { @@ -100,7 +100,7 @@ export const downloadFile = async (id: number, dataKey: CryptoKey, isLegacy: boo return await decryptFile( state, fileEncrypted, - isLegacy ? fileEncrypted.byteLength : CHUNK_SIZE + ENCRYPTION_OVERHEAD, + isLegacy ? fileEncrypted.byteLength : ENCRYPTED_CHUNK_SIZE, dataKey, ); } catch (e) { diff --git a/src/lib/modules/file/thumbnail.ts b/src/lib/modules/file/thumbnail.ts index ed40e13..b33a4af 100644 --- a/src/lib/modules/file/thumbnail.ts +++ b/src/lib/modules/file/thumbnail.ts @@ -1,7 +1,7 @@ import { LRUCache } from "lru-cache"; import { writable, type Writable } from "svelte/store"; import { browser } from "$app/environment"; -import { decryptData } from "$lib/modules/crypto"; +import { decryptChunk } from "$lib/modules/crypto"; import type { SummarizedFileInfo } from "$lib/modules/filesystem"; import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs"; import { getThumbnailUrl } from "$lib/modules/thumbnail"; @@ -20,12 +20,7 @@ const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => { const res = await fetch(`/api/file/${fileId}/thumbnail/download`); if (!res.ok) return null; - const thumbnailEncrypted = await res.arrayBuffer(); - const thumbnailBuffer = await decryptData( - thumbnailEncrypted.slice(12), - thumbnailEncrypted.slice(0, 12), - dataKey, - ); + const thumbnailBuffer = await decryptChunk(await res.arrayBuffer(), dataKey); void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); return getThumbnailUrl(thumbnailBuffer); diff --git a/src/lib/modules/file/upload.svelte.ts b/src/lib/modules/file/upload.svelte.ts index 9bf043a..6deac1f 100644 --- a/src/lib/modules/file/upload.svelte.ts +++ b/src/lib/modules/file/upload.svelte.ts @@ -58,8 +58,7 @@ const requestDuplicateFileScan = limitFunction( ) => { state.status = "encryption-pending"; - const hmacResult = await signMessageHmac(file, hmacSecret.secret); - const fileSigned = encodeToBase64(hmacResult); + const fileSigned = encodeToBase64(await signMessageHmac(file, hmacSecret.secret)); const files = await trpc().file.listByHash.query({ hskVersion: hmacSecret.version, contentHmac: fileSigned, @@ -171,7 +170,7 @@ const requestFileUpload = limitFunction( await uploadBlob(uploadId, file, dataKey, { onProgress(s) { state.progress = s.progress; - state.rate = s.rateBps; + state.rate = s.rate; }, }); diff --git a/src/lib/modules/upload.ts b/src/lib/modules/upload.ts index 83f4fd3..a540e22 100644 --- a/src/lib/modules/upload.ts +++ b/src/lib/modules/upload.ts @@ -3,27 +3,32 @@ import pLimit from "p-limit"; import { ENCRYPTION_OVERHEAD, CHUNK_SIZE } from "$lib/constants"; import { encryptChunk, digestMessage, encodeToBase64 } from "$lib/modules/crypto"; -type UploadStats = { - progress: number; // 0..1 (암호화 후 기준) - rateBps: number; // bytes/sec - uploadedBytes: number; - totalBytes: number; -}; +interface UploadStats { + progress: number; + rate: number; +} + +const createSpeedMeter = (timeWindow = 1500) => { + const samples: { t: number; b: number }[] = []; + let lastSpeed = 0; + + return (bytesNow?: number) => { + if (!bytesNow) return lastSpeed; -function createSpeedMeter(windowMs = 1500) { - const samples: Array<{ t: number; b: number }> = []; - return (bytesNow: number) => { const now = performance.now(); samples.push({ t: now, b: bytesNow }); - const cutoff = now - windowMs; + + const cutoff = now - timeWindow; while (samples.length > 2 && samples[0]!.t < cutoff) samples.shift(); const first = samples[0]!; const dt = now - first.t; const db = bytesNow - first.b; - return dt > 0 ? (db / dt) * 1000 : 0; + + lastSpeed = dt > 0 ? (db / dt) * 1000 : 0; + return lastSpeed; }; -} +}; const uploadChunk = async ( uploadId: string, @@ -66,10 +71,10 @@ export const uploadBlob = async ( if (!onProgress) return; const uploadedBytes = uploadedByChunk.reduce((a, b) => a + b, 0); - const rateBps = speedMeter(uploadedBytes); + const rate = speedMeter(uploadedBytes); const progress = Math.min(1, uploadedBytes / totalBytes); - onProgress({ progress, rateBps, uploadedBytes, totalBytes }); + onProgress({ progress, rate }); }; const onChunkProgress = (idx: number, loaded: number) => { @@ -84,7 +89,7 @@ export const uploadBlob = async ( limit(() => uploadChunk( uploadId, - i + 1, // 1-based chunk index + i + 1, blob.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE), dataKey, onChunkProgress, @@ -93,11 +98,5 @@ export const uploadBlob = async ( ), ); - // 완료 보정 - onProgress?.({ - progress: 1, - rateBps: 0, - uploadedBytes: totalBytes, - totalBytes, - }); + onProgress?.({ progress: 1, rate: speedMeter() }); }; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index 9314f4b..d0c54cc 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -497,21 +497,22 @@ export const migrateFileContent = async ( userId: number, fileId: number, newPath: string, + dekVersion: Date, encContentHash: string, ) => { const file = await trx .selectFrom("file") - .select(["path", "encrypted_content_iv"]) + .select(["path", "data_encryption_key_version", "encrypted_content_iv"]) .where("id", "=", fileId) .where("user_id", "=", userId) .limit(1) .forUpdate() .executeTakeFirst(); - if (!file) { throw new IntegrityError("File not found"); - } - if (!file.encrypted_content_iv) { + } else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) { + throw new IntegrityError("Invalid DEK version"); + } else if (!file.encrypted_content_iv) { throw new IntegrityError("File is not legacy"); } @@ -525,7 +526,6 @@ export const migrateFileContent = async ( .where("id", "=", fileId) .where("user_id", "=", userId) .execute(); - await trx .insertInto("file_log") .values({ @@ -534,8 +534,7 @@ export const migrateFileContent = async ( action: "migrate", }) .execute(); - - return file.path; + return { oldPath: file.path }; }; export const addFileToCategory = async (fileId: number, categoryId: number) => { diff --git a/src/lib/server/db/migrations/1768062380-AddChunkedUpload.ts b/src/lib/server/db/migrations/1768062380-AddChunkedUpload.ts index 26e6ae8..22676aa 100644 --- a/src/lib/server/db/migrations/1768062380-AddChunkedUpload.ts +++ b/src/lib/server/db/migrations/1768062380-AddChunkedUpload.ts @@ -52,11 +52,11 @@ export const up = async (db: Kysely) => { "hmac_secret_key", ["user_id", "version"], ) - .addCheckConstraint("upload_session_ck01", sql`uploaded_chunks <= total_chunks`) .addCheckConstraint( - "upload_session_ck02", + "upload_session_ck01", sql`length(bitmap) = ceil(total_chunks / 8.0)::integer`, ) + .addCheckConstraint("upload_session_ck02", sql`uploaded_chunks <= total_chunks`) .execute(); }; diff --git a/src/lib/server/db/schema/upload.ts b/src/lib/server/db/schema/upload.ts index 7aefc5d..5635921 100644 --- a/src/lib/server/db/schema/upload.ts +++ b/src/lib/server/db/schema/upload.ts @@ -11,7 +11,6 @@ interface UploadSessionTable { uploaded_chunks: Generated; expires_at: Date; - // For file uploads parent_id: number | null; master_encryption_key_version: number | null; encrypted_data_encryption_key: string | null; // Base64 @@ -21,8 +20,6 @@ interface UploadSessionTable { encrypted_name: Ciphertext | null; encrypted_created_at: Ciphertext | null; encrypted_last_modified_at: Ciphertext | null; - - // For thumbnail uploads file_id: number | null; } diff --git a/src/lib/server/db/upload.ts b/src/lib/server/db/upload.ts index db19cbf..9dd85a0 100644 --- a/src/lib/server/db/upload.ts +++ b/src/lib/server/db/upload.ts @@ -26,17 +26,12 @@ interface FileUploadSession extends BaseUploadSession { encLastModifiedAt: Ciphertext; } -interface ThumbnailUploadSession extends BaseUploadSession { - type: "thumbnail"; +interface ThumbnailOrMigrationUploadSession extends BaseUploadSession { + type: "thumbnail" | "migration"; fileId: number; dekVersion: Date; } -interface MigrationUploadSession extends BaseUploadSession { - type: "migration"; - fileId: number; -} - export const createFileUploadSession = async ( params: Omit, ) => { @@ -91,8 +86,8 @@ export const createFileUploadSession = async ( }); }; -export const createThumbnailUploadSession = async ( - params: Omit, +export const createThumbnailOrMigrationUploadSession = async ( + params: Omit, ) => { await db.transaction().execute(async (trx) => { const file = await trx @@ -113,7 +108,7 @@ export const createThumbnailUploadSession = async ( .insertInto("upload_session") .values({ id: params.id, - type: "thumbnail", + type: params.type, user_id: params.userId, path: params.path, bitmap: Buffer.alloc(Math.ceil(params.totalChunks / 8)), @@ -126,40 +121,6 @@ export const createThumbnailUploadSession = async ( }); }; -export const createMigrationUploadSession = async ( - params: Omit, -) => { - await db.transaction().execute(async (trx) => { - const file = await trx - .selectFrom("file") - .select("encrypted_content_iv") - .where("id", "=", params.fileId) - .where("user_id", "=", params.userId) - .limit(1) - .forUpdate() - .executeTakeFirst(); - if (!file) { - throw new IntegrityError("File not found"); - } else if (!file.encrypted_content_iv) { - throw new IntegrityError("File is not legacy"); - } - - await trx - .insertInto("upload_session") - .values({ - id: params.id, - type: "migration", - user_id: params.userId, - path: params.path, - bitmap: Buffer.alloc(Math.ceil(params.totalChunks / 8)), - total_chunks: params.totalChunks, - expires_at: params.expiresAt, - file_id: params.fileId, - }) - .execute(); - }); -}; - export const getUploadSession = async (sessionId: string, userId: number) => { const session = await db .selectFrom("upload_session") @@ -191,9 +152,9 @@ export const getUploadSession = async (sessionId: string, userId: number) => { encCreatedAt: session.encrypted_created_at, encLastModifiedAt: session.encrypted_last_modified_at!, } satisfies FileUploadSession; - } else if (session.type === "thumbnail") { + } else { return { - type: "thumbnail", + type: session.type, id: session.id, userId: session.user_id, path: session.path, @@ -203,19 +164,7 @@ export const getUploadSession = async (sessionId: string, userId: number) => { expiresAt: session.expires_at, fileId: session.file_id!, dekVersion: session.data_encryption_key_version!, - } satisfies ThumbnailUploadSession; - } else { - return { - type: "migration", - id: session.id, - userId: session.user_id, - path: session.path, - bitmap: session.bitmap, - totalChunks: session.total_chunks, - uploadedChunks: session.uploaded_chunks, - expiresAt: session.expires_at, - fileId: session.file_id!, - } satisfies MigrationUploadSession; + } satisfies ThumbnailOrMigrationUploadSession; } }; diff --git a/src/routes/(fullscreen)/settings/migration/service.svelte.ts b/src/routes/(fullscreen)/settings/migration/service.svelte.ts index 67201b0..dfb0edd 100644 --- a/src/routes/(fullscreen)/settings/migration/service.svelte.ts +++ b/src/routes/(fullscreen)/settings/migration/service.svelte.ts @@ -42,18 +42,25 @@ export const clearMigrationStates = () => { }; const requestFileUpload = limitFunction( - async (state: MigrationState, fileId: number, fileBuffer: ArrayBuffer, dataKey: CryptoKey) => { + async ( + state: MigrationState, + fileId: number, + fileBuffer: ArrayBuffer, + dataKey: CryptoKey, + dataKeyVersion: Date, + ) => { state.status = "uploading"; const { uploadId } = await trpc().upload.startMigrationUpload.mutate({ file: fileId, chunks: Math.ceil(fileBuffer.byteLength / CHUNK_SIZE), + dekVersion: dataKeyVersion, }); await uploadBlob(uploadId, new Blob([fileBuffer]), dataKey, { onProgress(s) { state.progress = s.progress; - state.rate = s.rateBps; + state.rate = s.rate; }, }); @@ -76,7 +83,7 @@ export const requestFileMigration = async (fileInfo: FileInfo) => { } try { - const dataKey = fileInfo.dataKey?.key; + const dataKey = fileInfo.dataKey; if (!dataKey) { throw new Error("Data key not available"); } @@ -86,10 +93,10 @@ export const requestFileMigration = async (fileInfo: FileInfo) => { await scheduler.schedule( async () => { state.status = "downloading"; - fileBuffer = await requestFileDownload(fileInfo.id, dataKey, true); + fileBuffer = await requestFileDownload(fileInfo.id, dataKey.key, true); return fileBuffer.byteLength; }, - () => requestFileUpload(state, fileInfo.id, fileBuffer!, dataKey), + () => requestFileUpload(state, fileInfo.id, fileBuffer!, dataKey.key, dataKey.version), ); } catch (e) { state.status = "error"; diff --git a/src/routes/(fullscreen)/settings/thumbnail/File.svelte b/src/routes/(fullscreen)/settings/thumbnail/File.svelte index 4440cf2..edb7e91 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/File.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/File.svelte @@ -3,7 +3,6 @@ queued: "대기 중", "generation-pending": "준비 중", generating: "생성하는 중", - "upload-pending": "업로드를 기다리는 중", uploading: "업로드하는 중", error: "실패", } as const; diff --git a/src/routes/(fullscreen)/settings/thumbnail/service.ts b/src/routes/(fullscreen)/settings/thumbnail/service.ts index 83b2890..fdf0303 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/service.ts +++ b/src/routes/(fullscreen)/settings/thumbnail/service.ts @@ -10,7 +10,6 @@ export type GenerationStatus = | "queued" | "generation-pending" | "generating" - | "upload-pending" | "uploading" | "uploaded" | "error"; @@ -39,6 +38,8 @@ const requestThumbnailUpload = limitFunction( ); if (!thumbnail) return false; + statuses.set(fileInfo.id, "uploading"); + const res = await requestFileThumbnailUpload( fileInfo.id, thumbnail, diff --git a/src/routes/api/upload/[id]/chunks/[index]/+server.ts b/src/routes/api/upload/[id]/chunks/[index]/+server.ts index 179030e..3b2e85b 100644 --- a/src/routes/api/upload/[id]/chunks/[index]/+server.ts +++ b/src/routes/api/upload/[id]/chunks/[index]/+server.ts @@ -1,6 +1,6 @@ import { error, text } from "@sveltejs/kit"; import { Readable } from "stream"; -import { ReadableStream } from "stream/web"; +import type { ReadableStream } from "stream/web"; import { z } from "zod"; import { parseContentDigestHeader } from "$lib/modules/http"; import { authorize } from "$lib/server/modules/auth"; diff --git a/src/service-worker/constants.ts b/src/service-worker/constants.ts new file mode 100644 index 0000000..4938d61 --- /dev/null +++ b/src/service-worker/constants.ts @@ -0,0 +1 @@ +export * from "../lib/constants"; diff --git a/src/service-worker/handlers/decryptFile.ts b/src/service-worker/handlers/decryptFile.ts index 22aa118..9aa9717 100644 --- a/src/service-worker/handlers/decryptFile.ts +++ b/src/service-worker/handlers/decryptFile.ts @@ -1,4 +1,4 @@ -import { DECRYPTED_FILE_URL_PREFIX, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../modules/constants"; +import { DECRYPTED_FILE_URL_PREFIX, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../constants"; import { decryptChunk, getEncryptedRange, getDecryptedSize } from "../modules/crypto"; import { parseRangeHeader, getContentRangeHeader } from "../modules/http"; import { getFile } from "../modules/opfs"; @@ -15,10 +15,13 @@ const createResponse = ( const headers: Record = { "Accept-Ranges": "bytes", "Content-Length": String(range.end - range.start + 1), - "Content-Type": contentType ?? "application/octet-stream", ...(isRangeRequest ? getContentRangeHeader(range) : {}), }; + if (contentType) { + headers["Content-Type"] = contentType; + } + if (downloadFilename) { headers["Content-Disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(downloadFilename)}`; diff --git a/src/service-worker/index.ts b/src/service-worker/index.ts index 051f8d9..2861166 100644 --- a/src/service-worker/index.ts +++ b/src/service-worker/index.ts @@ -3,7 +3,7 @@ /// /// -import { DECRYPTED_FILE_URL_PREFIX } from "./modules/constants"; +import { DECRYPTED_FILE_URL_PREFIX } from "./constants"; import { decryptFile } from "./handlers"; import { fileMetadataStore } from "./stores"; import type { ServiceWorkerMessage, ServiceWorkerResponse } from "./types"; diff --git a/src/service-worker/modules/constants.ts b/src/service-worker/modules/constants.ts deleted file mode 100644 index cca093e..0000000 --- a/src/service-worker/modules/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../lib/constants"; diff --git a/src/service-worker/modules/crypto.ts b/src/service-worker/modules/crypto.ts index 1afee74..ed35094 100644 --- a/src/service-worker/modules/crypto.ts +++ b/src/service-worker/modules/crypto.ts @@ -1,4 +1,4 @@ -import { ENCRYPTION_OVERHEAD, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "./constants"; +import { ENCRYPTION_OVERHEAD, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../constants"; export * from "../../lib/modules/crypto"; diff --git a/src/trpc/routers/upload.ts b/src/trpc/routers/upload.ts index 3289aad..11b0a84 100644 --- a/src/trpc/routers/upload.ts +++ b/src/trpc/routers/upload.ts @@ -1,7 +1,7 @@ import { TRPCError } from "@trpc/server"; import { createHash } from "crypto"; import { createReadStream, createWriteStream } from "fs"; -import { mkdir, rename } from "fs/promises"; +import { copyFile, mkdir } from "fs/promises"; import mime from "mime"; import { dirname } from "path"; import { v4 as uuidv4 } from "uuid"; @@ -13,6 +13,8 @@ import env from "$lib/server/loadenv"; import { safeRecursiveRm, safeUnlink } from "$lib/server/modules/filesystem"; import { router, roleProcedure } from "../init.server"; +const UPLOADS_EXPIRES = 24 * 3600 * 1000; // 24 hours + const sessionLocks = new Set(); const generateSessionId = async () => { @@ -60,7 +62,7 @@ const uploadRouter = router({ userId: ctx.session.userId, path, totalChunks: input.chunks, - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours + expiresAt: new Date(Date.now() + UPLOADS_EXPIRES), parentId: input.parent, mekVersion: input.mekVersion, encDek: input.dek, @@ -89,41 +91,6 @@ const uploadRouter = router({ } }), - startFileThumbnailUpload: roleProcedure["activeClient"] - .input( - z.object({ - file: z.int().positive(), - dekVersion: z.date(), - }), - ) - .mutation(async ({ ctx, input }) => { - const { id, path } = await generateSessionId(); - - try { - await UploadRepo.createThumbnailUploadSession({ - id, - userId: ctx.session.userId, - path, - totalChunks: 1, // Up to 4 MiB - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours - fileId: input.file, - dekVersion: input.dekVersion, - }); - return { uploadId: id }; - } catch (e) { - await safeRecursiveRm(path); - - if (e instanceof IntegrityError) { - if (e.message === "File not found") { - throw new TRPCError({ code: "NOT_FOUND", message: "File not found" }); - } else if (e.message === "Invalid DEK version") { - throw new TRPCError({ code: "BAD_REQUEST", message: "Mismatched DEK version" }); - } - } - throw e; - } - }), - completeFileUpload: roleProcedure["activeClient"] .input( z.object({ @@ -143,7 +110,7 @@ const uploadRouter = router({ try { const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId); - if (!session || session.type !== "file") { + if (session?.type !== "file") { throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" }); } else if ( (session.hskVersion && !input.contentHmac) || @@ -195,6 +162,42 @@ const uploadRouter = router({ } }), + startFileThumbnailUpload: roleProcedure["activeClient"] + .input( + z.object({ + file: z.int().positive(), + dekVersion: z.date(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { id, path } = await generateSessionId(); + + try { + await UploadRepo.createThumbnailOrMigrationUploadSession({ + id, + type: "thumbnail", + userId: ctx.session.userId, + path, + totalChunks: 1, // Up to 4 MiB + expiresAt: new Date(Date.now() + UPLOADS_EXPIRES), + fileId: input.file, + dekVersion: input.dekVersion, + }); + return { uploadId: id }; + } catch (e) { + await safeRecursiveRm(path); + + if (e instanceof IntegrityError) { + if (e.message === "File not found") { + throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" }); + } else if (e.message === "Invalid DEK version") { + throw new TRPCError({ code: "BAD_REQUEST", message: e.message }); + } + } + throw e; + } + }), + completeFileThumbnailUpload: roleProcedure["activeClient"] .input( z.object({ @@ -213,7 +216,7 @@ const uploadRouter = router({ try { const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId); - if (!session || session.type !== "thumbnail") { + if (session?.type !== "thumbnail") { throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" }); } else if (session.uploadedChunks < session.totalChunks) { throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" }); @@ -221,7 +224,7 @@ const uploadRouter = router({ thumbnailPath = `${env.thumbnailsPath}/${ctx.session.userId}/${uploadId}`; await mkdir(dirname(thumbnailPath), { recursive: true }); - await rename(`${session.path}/1`, thumbnailPath); + await copyFile(`${session.path}/1`, thumbnailPath); const oldThumbnailPath = await db.transaction().execute(async (trx) => { const oldPath = await MediaRepo.updateFileThumbnail( @@ -238,12 +241,10 @@ const uploadRouter = router({ await Promise.all([safeUnlink(oldThumbnailPath), safeRecursiveRm(session.path)]); } catch (e) { await safeUnlink(thumbnailPath); - if (e instanceof IntegrityError) { - if (e.message === "File not found") { - throw new TRPCError({ code: "NOT_FOUND", message: "File not found" }); - } else if (e.message === "Invalid DEK version") { - throw new TRPCError({ code: "BAD_REQUEST", message: "Mismatched DEK version" }); - } + + if (e instanceof IntegrityError && e.message === "Invalid DEK version") { + // DEK rotated after this upload started + throw new TRPCError({ code: "CONFLICT", message: e.message }); } throw e; } finally { @@ -256,19 +257,22 @@ const uploadRouter = router({ z.object({ file: z.int().positive(), chunks: z.int().positive(), + dekVersion: z.date(), }), ) .mutation(async ({ ctx, input }) => { const { id, path } = await generateSessionId(); try { - await UploadRepo.createMigrationUploadSession({ + await UploadRepo.createThumbnailOrMigrationUploadSession({ id, + type: "migration", userId: ctx.session.userId, path, totalChunks: input.chunks, - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours + expiresAt: new Date(Date.now() + UPLOADS_EXPIRES), fileId: input.file, + dekVersion: input.dekVersion, }); return { uploadId: id }; } catch (e) { @@ -276,9 +280,9 @@ const uploadRouter = router({ if (e instanceof IntegrityError) { if (e.message === "File not found") { - throw new TRPCError({ code: "NOT_FOUND", message: "File not found" }); + throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" }); } else if (e.message === "File is not legacy") { - throw new TRPCError({ code: "BAD_REQUEST", message: "File is not legacy" }); + throw new TRPCError({ code: "BAD_REQUEST", message: e.message }); } } throw e; @@ -303,7 +307,7 @@ const uploadRouter = router({ try { const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId); - if (!session || session.type !== "migration") { + if (session?.type !== "migration") { throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" }); } else if (session.uploadedChunks < session.totalChunks) { throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" }); @@ -328,11 +332,12 @@ const uploadRouter = router({ const hash = hashStream.digest("base64"); const oldPath = await db.transaction().execute(async (trx) => { - const oldPath = await FileRepo.migrateFileContent( + const { oldPath } = await FileRepo.migrateFileContent( trx, ctx.session.userId, session.fileId, filePath, + session.dekVersion!, hash, ); await UploadRepo.deleteUploadSession(trx, uploadId); @@ -342,12 +347,10 @@ const uploadRouter = router({ await Promise.all([safeUnlink(oldPath), safeRecursiveRm(session.path)]); } catch (e) { await safeUnlink(filePath); - if (e instanceof IntegrityError) { - if (e.message === "File not found") { - throw new TRPCError({ code: "NOT_FOUND", message: "File not found" }); - } else if (e.message === "File is not legacy") { - throw new TRPCError({ code: "BAD_REQUEST", message: "File is not legacy" }); - } + + if (e instanceof IntegrityError && e.message === "File is not legacy") { + // File migrated after this upload started + throw new TRPCError({ code: "CONFLICT", message: e.message }); } throw e; } finally {