4 Commits

Author SHA1 Message Date
static
a4912c8952 사소한 리팩토링 2026-01-12 20:50:19 +09:00
static
00b9858db7 업로드된 청크 목록을 비트맵을 활용해 효율적으로 저장하도록 개선 2026-01-12 18:37:36 +09:00
static
c778a4fb9e 파일 업로드 로직 리팩토링 2 2026-01-12 16:58:28 +09:00
static
e7dc96bb47 HMAC 계산을 Web Worker에서 처리하도록 변경 2026-01-12 15:16:43 +09:00
28 changed files with 379 additions and 399 deletions

View File

@@ -9,6 +9,7 @@ services:
volumes: volumes:
- ./data/library:/app/data/library - ./data/library:/app/data/library
- ./data/thumbnails:/app/data/thumbnails - ./data/thumbnails:/app/data/thumbnails
- ./data/uploads:/app/data/uploads
environment: environment:
# ArkVault # ArkVault
- DATABASE_HOST=database - DATABASE_HOST=database

View File

@@ -7,8 +7,8 @@ import {
cleanupExpiredSessions, cleanupExpiredSessions,
cleanupExpiredSessionUpgradeChallenges, cleanupExpiredSessionUpgradeChallenges,
} from "$lib/server/db/session"; } from "$lib/server/db/session";
import { cleanupExpiredUploadSessions } from "$lib/server/services/upload";
import { authenticate, setAgentInfo } from "$lib/server/middlewares"; import { authenticate, setAgentInfo } from "$lib/server/middlewares";
import { cleanupExpiredUploadSessions } from "$lib/server/services/upload";
export const init: ServerInit = async () => { export const init: ServerInit = async () => {
await migrateDB(); await migrateDB();

View File

@@ -1,5 +1,5 @@
import { hmac } from "@noble/hashes/hmac.js"; import HmacWorker from "$workers/hmac?worker";
import { sha256 } from "@noble/hashes/sha2.js"; import type { ComputeMessage, ResultMessage } from "$workers/hmac";
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);
@@ -18,10 +18,24 @@ export const generateHmacSecret = async () => {
}; };
}; };
export const createHmacStream = async (hmacSecret: CryptoKey) => { export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => {
const h = hmac.create(sha256, new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret))); const stream = message.stream();
return { const hmacSecretRaw = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret));
update: (data: Uint8Array) => h.update(data), const worker = new HmacWorker();
digest: () => h.digest(),
return new Promise<Uint8Array>((resolve, reject) => {
worker.onmessage = ({ data }: MessageEvent<ResultMessage>) => {
resolve(data.result);
worker.terminate();
}; };
worker.onerror = ({ error }) => {
reject(error);
worker.terminate();
};
worker.postMessage({ stream, key: hmacSecretRaw } satisfies ComputeMessage, {
transfer: [stream, hmacSecretRaw.buffer],
});
});
}; };

View File

@@ -1,6 +1,6 @@
import axios from "axios"; import axios from "axios";
import { limitFunction } from "p-limit"; 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"; import { decryptChunk, concatenateBuffers } from "$lib/modules/crypto";
export interface FileDownloadState { export interface FileDownloadState {
@@ -100,7 +100,7 @@ export const downloadFile = async (id: number, dataKey: CryptoKey, isLegacy: boo
return await decryptFile( return await decryptFile(
state, state,
fileEncrypted, fileEncrypted,
isLegacy ? fileEncrypted.byteLength : CHUNK_SIZE + ENCRYPTION_OVERHEAD, isLegacy ? fileEncrypted.byteLength : ENCRYPTED_CHUNK_SIZE,
dataKey, dataKey,
); );
} catch (e) { } catch (e) {

View File

@@ -1,7 +1,7 @@
import { LRUCache } from "lru-cache"; import { LRUCache } from "lru-cache";
import { writable, type Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
import { browser } from "$app/environment"; 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 type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs"; import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
import { getThumbnailUrl } from "$lib/modules/thumbnail"; 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`); const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
if (!res.ok) return null; if (!res.ok) return null;
const thumbnailEncrypted = await res.arrayBuffer(); const thumbnailBuffer = await decryptChunk(await res.arrayBuffer(), dataKey);
const thumbnailBuffer = await decryptData(
thumbnailEncrypted.slice(12),
thumbnailEncrypted.slice(0, 12),
dataKey,
);
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
return getThumbnailUrl(thumbnailBuffer); return getThumbnailUrl(thumbnailBuffer);

View File

@@ -1,13 +1,8 @@
import ExifReader from "exifreader"; import ExifReader from "exifreader";
import { limitFunction } from "p-limit"; import { limitFunction } from "p-limit";
import { CHUNK_SIZE } from "$lib/constants"; import { CHUNK_SIZE } from "$lib/constants";
import { import { encodeToBase64, generateDataKey, wrapDataKey, encryptString } from "$lib/modules/crypto";
encodeToBase64, import { signMessageHmac } from "$lib/modules/crypto";
generateDataKey,
wrapDataKey,
encryptString,
createHmacStream,
} from "$lib/modules/crypto";
import { Scheduler } from "$lib/modules/scheduler"; import { Scheduler } from "$lib/modules/scheduler";
import { generateThumbnail } from "$lib/modules/thumbnail"; import { generateThumbnail } from "$lib/modules/thumbnail";
import { uploadBlob } from "$lib/modules/upload"; import { uploadBlob } from "$lib/modules/upload";
@@ -55,17 +50,15 @@ export const clearUploadedFiles = () => {
}; };
const requestDuplicateFileScan = limitFunction( const requestDuplicateFileScan = limitFunction(
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => { async (
const hmacStream = await createHmacStream(hmacSecret.secret); state: FileUploadState,
const reader = file.stream().getReader(); file: File,
hmacSecret: HmacSecret,
onDuplicate: () => Promise<boolean>,
) => {
state.status = "encryption-pending";
while (true) { const fileSigned = encodeToBase64(await signMessageHmac(file, hmacSecret.secret));
const { done, value } = await reader.read();
if (done) break;
hmacStream.update(value);
}
const fileSigned = encodeToBase64(hmacStream.digest());
const files = await trpc().file.listByHash.query({ const files = await trpc().file.listByHash.query({
hskVersion: hmacSecret.version, hskVersion: hmacSecret.version,
contentHmac: fileSigned, contentHmac: fileSigned,
@@ -111,19 +104,21 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
return new Date(utcDate - offsetMs); return new Date(utcDate - offsetMs);
}; };
const requestFileUpload2 = async ( interface FileMetadata {
state: FileUploadState,
file: Blob,
fileSigned: string,
fileMetadata: {
parentId: "root" | number; parentId: "root" | number;
name: string; name: string;
createdAt?: Date; createdAt?: Date;
lastModifiedAt: Date; lastModifiedAt: Date;
}, }
const requestFileMetadataEncryption = limitFunction(
async (
state: FileUploadState,
file: Blob,
fileMetadata: FileMetadata,
masterKey: MasterKey, masterKey: MasterKey,
hmacSecret: HmacSecret, hmacSecret: HmacSecret,
) => { ) => {
state.status = "encrypting"; state.status = "encrypting";
const { dataKey, dataKeyVersion } = await generateDataKey(); const { dataKey, dataKeyVersion } = await generateDataKey();
@@ -132,7 +127,8 @@ const requestFileUpload2 = async (
const [nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, thumbnailBuffer] = const [nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, thumbnailBuffer] =
await Promise.all([ await Promise.all([
encryptString(fileMetadata.name, dataKey), encryptString(fileMetadata.name, dataKey),
fileMetadata.createdAt && encryptString(fileMetadata.createdAt.getTime().toString(), dataKey), fileMetadata.createdAt &&
encryptString(fileMetadata.createdAt.getTime().toString(), dataKey),
encryptString(fileMetadata.lastModifiedAt.getTime().toString(), dataKey), encryptString(fileMetadata.lastModifiedAt.getTime().toString(), dataKey),
generateThumbnail(file).then((blob) => blob?.arrayBuffer()), generateThumbnail(file).then((blob) => blob?.arrayBuffer()),
]); ]);
@@ -153,12 +149,28 @@ const requestFileUpload2 = async (
lastModifiedAtIv: lastModifiedAtEncrypted.iv, lastModifiedAtIv: lastModifiedAtEncrypted.iv,
}); });
state.status = "upload-pending";
return { uploadId, thumbnailBuffer, dataKey, dataKeyVersion };
},
{ concurrency: 4 },
);
const requestFileUpload = limitFunction(
async (
state: FileUploadState,
uploadId: string,
file: Blob,
fileSigned: string,
thumbnailBuffer: ArrayBuffer | undefined,
dataKey: CryptoKey,
dataKeyVersion: Date,
) => {
state.status = "uploading"; state.status = "uploading";
await uploadBlob(uploadId, file, dataKey, { await uploadBlob(uploadId, file, dataKey, {
onProgress(s) { onProgress(s) {
state.progress = s.progress; state.progress = s.progress;
state.rate = s.rateBps; state.rate = s.rate;
}, },
}); });
@@ -168,6 +180,7 @@ const requestFileUpload2 = async (
}); });
if (thumbnailBuffer) { if (thumbnailBuffer) {
try {
const { uploadId } = await trpc().upload.startFileThumbnailUpload.mutate({ const { uploadId } = await trpc().upload.startFileThumbnailUpload.mutate({
file: fileId, file: fileId,
dekVersion: dataKeyVersion, dekVersion: dataKeyVersion,
@@ -176,12 +189,16 @@ const requestFileUpload2 = async (
await uploadBlob(uploadId, new Blob([thumbnailBuffer]), dataKey); await uploadBlob(uploadId, new Blob([thumbnailBuffer]), dataKey);
await trpc().upload.completeFileThumbnailUpload.mutate({ uploadId }); await trpc().upload.completeFileThumbnailUpload.mutate({ uploadId });
} catch (e) {
console.error(e);
}
} }
state.status = "uploaded"; state.status = "uploaded";
return { fileId };
return { fileId, thumbnailBuffer }; },
}; { concurrency: 1 },
);
export const uploadFile = async ( export const uploadFile = async (
file: File, file: File,
@@ -198,51 +215,44 @@ export const uploadFile = async (
const state = uploadingFiles.at(-1)!; const state = uploadingFiles.at(-1)!;
return await scheduler.schedule(file.size, async () => { return await scheduler.schedule(file.size, async () => {
state.status = "encryption-pending";
try { try {
const { fileSigned } = await requestDuplicateFileScan(file, hmacSecret, onDuplicate); const { fileSigned } = await requestDuplicateFileScan(state, file, hmacSecret, onDuplicate);
if (!fileSigned) { if (!fileSigned) {
state.status = "canceled"; state.status = "canceled";
uploadingFiles = uploadingFiles.filter((file) => file !== state); uploadingFiles = uploadingFiles.filter((file) => file !== state);
return; return;
} }
let fileBuffer;
const fileType = getFileType(file); const fileType = getFileType(file);
if (fileType.startsWith("image/")) { const fileMetadata: FileMetadata = {
const fileBuffer = await file.arrayBuffer();
const fileCreatedAt = extractExifDateTime(fileBuffer);
const { fileId, thumbnailBuffer } = await requestFileUpload2(
state,
new Blob([fileBuffer], { type: fileType }),
fileSigned,
{
parentId, parentId,
name: file.name, name: file.name,
createdAt: fileCreatedAt,
lastModifiedAt: new Date(file.lastModified), lastModifiedAt: new Date(file.lastModified),
}, };
masterKey,
hmacSecret, if (fileType.startsWith("image/")) {
fileBuffer = await file.arrayBuffer();
fileMetadata.createdAt = extractExifDateTime(fileBuffer);
}
const blob = new Blob([file], { type: fileType });
const { uploadId, thumbnailBuffer, dataKey, dataKeyVersion } =
await requestFileMetadataEncryption(state, blob, fileMetadata, masterKey, hmacSecret);
const { fileId } = await requestFileUpload(
state,
uploadId,
blob,
fileSigned,
thumbnailBuffer,
dataKey,
dataKeyVersion,
); );
return { fileId, fileBuffer, thumbnailBuffer }; return { fileId, fileBuffer, thumbnailBuffer };
} else {
const { fileId, thumbnailBuffer } = await requestFileUpload2(
state,
file,
fileSigned,
{
parentId,
name: file.name,
lastModifiedAt: new Date(file.lastModified),
},
masterKey,
hmacSecret,
);
return { fileId, thumbnailBuffer };
}
} catch (e) { } catch (e) {
state.status = "error"; state.status = "error";
throw e; throw e;

View File

@@ -3,27 +3,32 @@ import pLimit from "p-limit";
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE } from "$lib/constants"; import { ENCRYPTION_OVERHEAD, CHUNK_SIZE } from "$lib/constants";
import { encryptChunk, digestMessage, encodeToBase64 } from "$lib/modules/crypto"; import { encryptChunk, digestMessage, encodeToBase64 } from "$lib/modules/crypto";
type UploadStats = { interface UploadStats {
progress: number; // 0..1 (암호화 후 기준) progress: number;
rateBps: number; // bytes/sec rate: number;
uploadedBytes: number; }
totalBytes: 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(); const now = performance.now();
samples.push({ t: now, b: bytesNow }); samples.push({ t: now, b: bytesNow });
const cutoff = now - windowMs;
const cutoff = now - timeWindow;
while (samples.length > 2 && samples[0]!.t < cutoff) samples.shift(); while (samples.length > 2 && samples[0]!.t < cutoff) samples.shift();
const first = samples[0]!; const first = samples[0]!;
const dt = now - first.t; const dt = now - first.t;
const db = bytesNow - first.b; const db = bytesNow - first.b;
return dt > 0 ? (db / dt) * 1000 : 0;
lastSpeed = dt > 0 ? (db / dt) * 1000 : 0;
return lastSpeed;
}; };
} };
const uploadChunk = async ( const uploadChunk = async (
uploadId: string, uploadId: string,
@@ -66,10 +71,10 @@ export const uploadBlob = async (
if (!onProgress) return; if (!onProgress) return;
const uploadedBytes = uploadedByChunk.reduce((a, b) => a + b, 0); const uploadedBytes = uploadedByChunk.reduce((a, b) => a + b, 0);
const rateBps = speedMeter(uploadedBytes); const rate = speedMeter(uploadedBytes);
const progress = Math.min(1, uploadedBytes / totalBytes); const progress = Math.min(1, uploadedBytes / totalBytes);
onProgress({ progress, rateBps, uploadedBytes, totalBytes }); onProgress({ progress, rate });
}; };
const onChunkProgress = (idx: number, loaded: number) => { const onChunkProgress = (idx: number, loaded: number) => {
@@ -80,12 +85,12 @@ export const uploadBlob = async (
const limit = pLimit(options?.concurrency ?? 4); const limit = pLimit(options?.concurrency ?? 4);
await Promise.all( await Promise.all(
Array.from({ length: totalChunks }, (_, chunkIndex) => Array.from({ length: totalChunks }, (_, i) =>
limit(() => limit(() =>
uploadChunk( uploadChunk(
uploadId, uploadId,
chunkIndex, i + 1,
blob.slice(chunkIndex * CHUNK_SIZE, (chunkIndex + 1) * CHUNK_SIZE), blob.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE),
dataKey, dataKey,
onChunkProgress, onChunkProgress,
), ),
@@ -93,11 +98,5 @@ export const uploadBlob = async (
), ),
); );
// 완료 보정 onProgress?.({ progress: 1, rate: speedMeter() });
onProgress?.({
progress: 1,
rateBps: 0,
uploadedBytes: totalBytes,
totalBytes,
});
}; };

View File

@@ -497,21 +497,22 @@ export const migrateFileContent = async (
userId: number, userId: number,
fileId: number, fileId: number,
newPath: string, newPath: string,
dekVersion: Date,
encContentHash: string, encContentHash: string,
) => { ) => {
const file = await trx const file = await trx
.selectFrom("file") .selectFrom("file")
.select(["path", "encrypted_content_iv"]) .select(["path", "data_encryption_key_version", "encrypted_content_iv"])
.where("id", "=", fileId) .where("id", "=", fileId)
.where("user_id", "=", userId) .where("user_id", "=", userId)
.limit(1) .limit(1)
.forUpdate() .forUpdate()
.executeTakeFirst(); .executeTakeFirst();
if (!file) { if (!file) {
throw new IntegrityError("File not found"); throw new IntegrityError("File not found");
} } else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
if (!file.encrypted_content_iv) { throw new IntegrityError("Invalid DEK version");
} else if (!file.encrypted_content_iv) {
throw new IntegrityError("File is not legacy"); throw new IntegrityError("File is not legacy");
} }
@@ -525,7 +526,6 @@ export const migrateFileContent = async (
.where("id", "=", fileId) .where("id", "=", fileId)
.where("user_id", "=", userId) .where("user_id", "=", userId)
.execute(); .execute();
await trx await trx
.insertInto("file_log") .insertInto("file_log")
.values({ .values({
@@ -534,8 +534,7 @@ export const migrateFileContent = async (
action: "migrate", action: "migrate",
}) })
.execute(); .execute();
return { oldPath: file.path };
return file.path;
}; };
export const addFileToCategory = async (fileId: number, categoryId: number) => { export const addFileToCategory = async (fileId: number, categoryId: number) => {

View File

@@ -21,8 +21,14 @@ export const up = async (db: Kysely<any>) => {
.addColumn("type", "text", (col) => col.notNull()) .addColumn("type", "text", (col) => col.notNull())
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull()) .addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("path", "text", (col) => col.notNull()) .addColumn("path", "text", (col) => col.notNull())
.addColumn("bitmap", "bytea", (col) => col.notNull())
.addColumn("total_chunks", "integer", (col) => col.notNull()) .addColumn("total_chunks", "integer", (col) => col.notNull())
.addColumn("uploaded_chunks", sql`integer[]`, (col) => col.notNull().defaultTo(sql`'{}'`)) .addColumn("uploaded_chunks", "integer", (col) =>
col
.generatedAlwaysAs(sql`bit_count(bitmap)`)
.stored()
.notNull(),
)
.addColumn("expires_at", "timestamp(3)", (col) => col.notNull()) .addColumn("expires_at", "timestamp(3)", (col) => col.notNull())
.addColumn("parent_id", "integer", (col) => col.references("directory.id")) .addColumn("parent_id", "integer", (col) => col.references("directory.id"))
.addColumn("master_encryption_key_version", "integer") .addColumn("master_encryption_key_version", "integer")
@@ -46,6 +52,11 @@ export const up = async (db: Kysely<any>) => {
"hmac_secret_key", "hmac_secret_key",
["user_id", "version"], ["user_id", "version"],
) )
.addCheckConstraint(
"upload_session_ck01",
sql`length(bitmap) = ceil(total_chunks / 8.0)::integer`,
)
.addCheckConstraint("upload_session_ck02", sql`uploaded_chunks <= total_chunks`)
.execute(); .execute();
}; };

View File

@@ -6,11 +6,11 @@ interface UploadSessionTable {
type: "file" | "thumbnail" | "migration"; type: "file" | "thumbnail" | "migration";
user_id: number; user_id: number;
path: string; path: string;
bitmap: Buffer;
total_chunks: number; total_chunks: number;
uploaded_chunks: Generated<number[]>; uploaded_chunks: Generated<number>;
expires_at: Date; expires_at: Date;
// For file uploads
parent_id: number | null; parent_id: number | null;
master_encryption_key_version: number | null; master_encryption_key_version: number | null;
encrypted_data_encryption_key: string | null; // Base64 encrypted_data_encryption_key: string | null; // Base64
@@ -20,8 +20,6 @@ interface UploadSessionTable {
encrypted_name: Ciphertext | null; encrypted_name: Ciphertext | null;
encrypted_created_at: Ciphertext | null; encrypted_created_at: Ciphertext | null;
encrypted_last_modified_at: Ciphertext | null; encrypted_last_modified_at: Ciphertext | null;
// For thumbnail uploads
file_id: number | null; file_id: number | null;
} }

View File

@@ -7,8 +7,9 @@ interface BaseUploadSession {
id: string; id: string;
userId: number; userId: number;
path: string; path: string;
bitmap: Buffer;
totalChunks: number; totalChunks: number;
uploadedChunks: number[]; uploadedChunks: number;
expiresAt: Date; expiresAt: Date;
} }
@@ -25,19 +26,14 @@ interface FileUploadSession extends BaseUploadSession {
encLastModifiedAt: Ciphertext; encLastModifiedAt: Ciphertext;
} }
interface ThumbnailUploadSession extends BaseUploadSession { interface ThumbnailOrMigrationUploadSession extends BaseUploadSession {
type: "thumbnail"; type: "thumbnail" | "migration";
fileId: number; fileId: number;
dekVersion: Date; dekVersion: Date;
} }
interface MigrationUploadSession extends BaseUploadSession {
type: "migration";
fileId: number;
}
export const createFileUploadSession = async ( export const createFileUploadSession = async (
params: Omit<FileUploadSession, "type" | "uploadedChunks">, params: Omit<FileUploadSession, "type" | "bitmap" | "uploadedChunks">,
) => { ) => {
await db.transaction().execute(async (trx) => { await db.transaction().execute(async (trx) => {
const mek = await trx const mek = await trx
@@ -73,6 +69,7 @@ export const createFileUploadSession = async (
type: "file", type: "file",
user_id: params.userId, user_id: params.userId,
path: params.path, path: params.path,
bitmap: Buffer.alloc(Math.ceil(params.totalChunks / 8)),
total_chunks: params.totalChunks, total_chunks: params.totalChunks,
expires_at: params.expiresAt, expires_at: params.expiresAt,
parent_id: params.parentId !== "root" ? params.parentId : null, parent_id: params.parentId !== "root" ? params.parentId : null,
@@ -89,8 +86,8 @@ export const createFileUploadSession = async (
}); });
}; };
export const createThumbnailUploadSession = async ( export const createThumbnailOrMigrationUploadSession = async (
params: Omit<ThumbnailUploadSession, "type" | "uploadedChunks">, params: Omit<ThumbnailOrMigrationUploadSession, "bitmap" | "uploadedChunks">,
) => { ) => {
await db.transaction().execute(async (trx) => { await db.transaction().execute(async (trx) => {
const file = await trx const file = await trx
@@ -111,9 +108,10 @@ export const createThumbnailUploadSession = async (
.insertInto("upload_session") .insertInto("upload_session")
.values({ .values({
id: params.id, id: params.id,
type: "thumbnail", type: params.type,
user_id: params.userId, user_id: params.userId,
path: params.path, path: params.path,
bitmap: Buffer.alloc(Math.ceil(params.totalChunks / 8)),
total_chunks: params.totalChunks, total_chunks: params.totalChunks,
expires_at: params.expiresAt, expires_at: params.expiresAt,
file_id: params.fileId, file_id: params.fileId,
@@ -123,39 +121,6 @@ export const createThumbnailUploadSession = async (
}); });
}; };
export const createMigrationUploadSession = async (
params: Omit<MigrationUploadSession, "type" | "uploadedChunks">,
) => {
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,
total_chunks: params.totalChunks,
expires_at: params.expiresAt,
file_id: params.fileId,
})
.execute();
});
};
export const getUploadSession = async (sessionId: string, userId: number) => { export const getUploadSession = async (sessionId: string, userId: number) => {
const session = await db const session = await db
.selectFrom("upload_session") .selectFrom("upload_session")
@@ -173,6 +138,7 @@ export const getUploadSession = async (sessionId: string, userId: number) => {
id: session.id, id: session.id,
userId: session.user_id, userId: session.user_id,
path: session.path, path: session.path,
bitmap: session.bitmap,
totalChunks: session.total_chunks, totalChunks: session.total_chunks,
uploadedChunks: session.uploaded_chunks, uploadedChunks: session.uploaded_chunks,
expiresAt: session.expires_at, expiresAt: session.expires_at,
@@ -186,36 +152,28 @@ export const getUploadSession = async (sessionId: string, userId: number) => {
encCreatedAt: session.encrypted_created_at, encCreatedAt: session.encrypted_created_at,
encLastModifiedAt: session.encrypted_last_modified_at!, encLastModifiedAt: session.encrypted_last_modified_at!,
} satisfies FileUploadSession; } satisfies FileUploadSession;
} else if (session.type === "thumbnail") { } else {
return { return {
type: "thumbnail", type: session.type,
id: session.id, id: session.id,
userId: session.user_id, userId: session.user_id,
path: session.path, path: session.path,
bitmap: session.bitmap,
totalChunks: session.total_chunks, totalChunks: session.total_chunks,
uploadedChunks: session.uploaded_chunks, uploadedChunks: session.uploaded_chunks,
expiresAt: session.expires_at, expiresAt: session.expires_at,
fileId: session.file_id!, fileId: session.file_id!,
dekVersion: session.data_encryption_key_version!, dekVersion: session.data_encryption_key_version!,
} satisfies ThumbnailUploadSession; } satisfies ThumbnailOrMigrationUploadSession;
} else {
return {
type: "migration",
id: session.id,
userId: session.user_id,
path: session.path,
totalChunks: session.total_chunks,
uploadedChunks: session.uploaded_chunks,
expiresAt: session.expires_at,
fileId: session.file_id!,
} satisfies MigrationUploadSession;
} }
}; };
export const markChunkAsUploaded = async (sessionId: string, chunkIndex: number) => { export const markChunkAsUploaded = async (sessionId: string, chunkIndex: number) => {
await db await db
.updateTable("upload_session") .updateTable("upload_session")
.set({ uploaded_chunks: sql`array_append(uploaded_chunks, ${chunkIndex})` }) .set({
bitmap: sql`set_bit(${sql.ref("bitmap")}, ${chunkIndex - 1}, 1)`,
})
.where("id", "=", sessionId) .where("id", "=", sessionId)
.execute(); .execute();
}; };

View File

@@ -8,6 +8,12 @@ import { safeRecursiveRm, safeUnlink } from "$lib/server/modules/filesystem";
const chunkLocks = new Set<string>(); const chunkLocks = new Set<string>();
const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => {
chunkIndex -= 1;
const byte = bitmap[Math.floor(chunkIndex / 8)];
return !!byte && (byte & (1 << (chunkIndex % 8))) !== 0; // Postgres sucks
};
export const uploadChunk = async ( export const uploadChunk = async (
userId: number, userId: number,
sessionId: string, sessionId: string,
@@ -28,13 +34,13 @@ export const uploadChunk = async (
const session = await UploadRepo.getUploadSession(sessionId, userId); const session = await UploadRepo.getUploadSession(sessionId, userId);
if (!session) { if (!session) {
error(404, "Invalid upload id"); error(404, "Invalid upload id");
} else if (chunkIndex >= session.totalChunks) { } else if (chunkIndex > session.totalChunks) {
error(400, "Invalid chunk index"); error(400, "Invalid chunk index");
} else if (session.uploadedChunks.includes(chunkIndex)) { } else if (isChunkUploaded(session.bitmap, chunkIndex)) {
error(409, "Chunk already uploaded"); error(409, "Chunk already uploaded");
} }
const isLastChunk = chunkIndex === session.totalChunks - 1; const isLastChunk = chunkIndex === session.totalChunks;
filePath = `${session.path}/${chunkIndex}`; filePath = `${session.path}/${chunkIndex}`;
const hashStream = createHash("sha256"); const hashStream = createHash("sha256");

View File

@@ -1,5 +1,4 @@
import { getAllFileInfos } from "$lib/indexedDB/filesystem"; import { getAllFileInfos } from "$lib/indexedDB/filesystem";
import { encodeToBase64, digestMessage } from "$lib/modules/crypto";
import { import {
getFileCache, getFileCache,
storeFileCache, storeFileCache,
@@ -7,6 +6,7 @@ import {
downloadFile, downloadFile,
deleteFileThumbnailCache, deleteFileThumbnailCache,
} from "$lib/modules/file"; } from "$lib/modules/file";
import { uploadBlob } from "$lib/modules/upload";
import { trpc } from "$trpc/client"; import { trpc } from "$trpc/client";
export const requestFileDownload = async ( export const requestFileDownload = async (
@@ -24,41 +24,24 @@ export const requestFileDownload = async (
export const requestFileThumbnailUpload = async ( export const requestFileThumbnailUpload = async (
fileId: number, fileId: number,
thumbnail: Blob,
dataKey: CryptoKey,
dataKeyVersion: Date, dataKeyVersion: Date,
thumbnailEncrypted: { ciphertext: ArrayBuffer; iv: ArrayBuffer },
) => { ) => {
try {
const { uploadId } = await trpc().upload.startFileThumbnailUpload.mutate({ const { uploadId } = await trpc().upload.startFileThumbnailUpload.mutate({
file: fileId, file: fileId,
dekVersion: dataKeyVersion, dekVersion: dataKeyVersion,
}); });
// Prepend IV to ciphertext (consistent with file download format) await uploadBlob(uploadId, thumbnail, dataKey);
const ivAndCiphertext = new Uint8Array(
thumbnailEncrypted.iv.byteLength + thumbnailEncrypted.ciphertext.byteLength,
);
ivAndCiphertext.set(new Uint8Array(thumbnailEncrypted.iv), 0);
ivAndCiphertext.set(
new Uint8Array(thumbnailEncrypted.ciphertext),
thumbnailEncrypted.iv.byteLength,
);
const chunkHash = encodeToBase64(await digestMessage(ivAndCiphertext));
const response = await fetch(`/api/upload/${uploadId}/chunks/0`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"Content-Digest": `sha-256=:${chunkHash}:`,
},
body: ivAndCiphertext,
});
if (!response.ok) {
throw new Error(`Thumbnail upload failed: ${response.status} ${response.statusText}`);
}
await trpc().upload.completeFileThumbnailUpload.mutate({ uploadId }); await trpc().upload.completeFileThumbnailUpload.mutate({ uploadId });
return response; return true;
} catch {
// TODO: Error Handling
return false;
}
}; };
export const requestDeletedFilesCleanup = async () => { export const requestDeletedFilesCleanup = async () => {

View File

@@ -1,4 +1,3 @@
import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file"; import { storeFileThumbnailCache } from "$lib/modules/file";
import { prepareFileDecryption, getDecryptedFileUrl } from "$lib/serviceWorker"; import { prepareFileDecryption, getDecryptedFileUrl } from "$lib/serviceWorker";
import { requestFileThumbnailUpload } from "$lib/services/file"; import { requestFileThumbnailUpload } from "$lib/services/file";
@@ -33,12 +32,10 @@ export const requestThumbnailUpload = async (
dataKey: CryptoKey, dataKey: CryptoKey,
dataKeyVersion: Date, dataKeyVersion: Date,
) => { ) => {
const thumbnailBuffer = await thumbnail.arrayBuffer(); const res = await requestFileThumbnailUpload(fileId, thumbnail, dataKey, dataKeyVersion);
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey); if (!res) return false;
const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnailEncrypted);
if (!res.ok) return false;
storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended void thumbnail.arrayBuffer().then((buffer) => storeFileThumbnailCache(fileId, buffer));
return true; return true;
}; };

View File

@@ -18,7 +18,7 @@
info, info,
state: getMigrationState(info.id), state: getMigrationState(info.id),
})) }))
.filter((file) => file.state?.status !== "completed"), .filter((file) => file.state?.status !== "uploaded"),
); );
const migrateAllFiles = () => { const migrateAllFiles = () => {

View File

@@ -1,12 +1,9 @@
<script module lang="ts"> <script module lang="ts">
const subtexts = { const subtexts = {
queued: "대기 중", queued: "대기 중",
"download-pending": "다운로드를 기다리는 중",
downloading: "다운로드하는 중", downloading: "다운로드하는 중",
"encryption-pending": "암호화를 기다리는 중",
encrypting: "암호화하는 중",
"upload-pending": "업로드를 기다리는 중", "upload-pending": "업로드를 기다리는 중",
completed: "완료", uploaded: "",
error: "실패", error: "실패",
} as const; } as const;
</script> </script>

View File

@@ -9,13 +9,10 @@ import { trpc } from "$trpc/client";
export type MigrationStatus = export type MigrationStatus =
| "queued" | "queued"
| "download-pending"
| "downloading" | "downloading"
| "encryption-pending"
| "encrypting"
| "upload-pending" | "upload-pending"
| "uploading" | "uploading"
| "completed" | "uploaded"
| "error"; | "error";
export interface MigrationState { export interface MigrationState {
@@ -38,29 +35,37 @@ export const getMigrationState = (fileId: number) => {
export const clearMigrationStates = () => { export const clearMigrationStates = () => {
for (const [id, state] of states) { for (const [id, state] of states) {
if (state.status === "completed" || state.status === "error") { if (state.status === "uploaded" || state.status === "error") {
states.delete(id); states.delete(id);
} }
} }
}; };
const uploadMigrationChunks = limitFunction( 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"; state.status = "uploading";
const { uploadId } = await trpc().upload.startMigrationUpload.mutate({ const { uploadId } = await trpc().upload.startMigrationUpload.mutate({
file: fileId, file: fileId,
chunks: Math.ceil(fileBuffer.byteLength / CHUNK_SIZE), chunks: Math.ceil(fileBuffer.byteLength / CHUNK_SIZE),
dekVersion: dataKeyVersion,
}); });
await uploadBlob(uploadId, new Blob([fileBuffer]), dataKey, { await uploadBlob(uploadId, new Blob([fileBuffer]), dataKey, {
onProgress(s) { onProgress(s) {
state.progress = s.progress; state.progress = s.progress;
state.rate = s.rateBps; state.rate = s.rate;
}, },
}); });
await trpc().upload.completeMigrationUpload.mutate({ uploadId }); await trpc().upload.completeMigrationUpload.mutate({ uploadId });
state.status = "uploaded";
}, },
{ concurrency: 1 }, { concurrency: 1 },
); );
@@ -78,7 +83,7 @@ export const requestFileMigration = async (fileInfo: FileInfo) => {
} }
try { try {
const dataKey = fileInfo.dataKey?.key; const dataKey = fileInfo.dataKey;
if (!dataKey) { if (!dataKey) {
throw new Error("Data key not available"); throw new Error("Data key not available");
} }
@@ -87,18 +92,11 @@ export const requestFileMigration = async (fileInfo: FileInfo) => {
await scheduler.schedule( await scheduler.schedule(
async () => { async () => {
state.status = "download-pending";
state.status = "downloading"; state.status = "downloading";
fileBuffer = await requestFileDownload(fileInfo.id, dataKey, true); fileBuffer = await requestFileDownload(fileInfo.id, dataKey.key, true);
return fileBuffer.byteLength; return fileBuffer.byteLength;
}, },
async () => { () => requestFileUpload(state, fileInfo.id, fileBuffer!, dataKey.key, dataKey.version),
state.status = "encryption-pending";
await uploadMigrationChunks(state, fileInfo.id, fileBuffer!, dataKey);
state.status = "completed";
},
); );
} catch (e) { } catch (e) {
state.status = "error"; state.status = "error";

View File

@@ -3,7 +3,6 @@
queued: "대기 중", queued: "대기 중",
"generation-pending": "준비 중", "generation-pending": "준비 중",
generating: "생성하는 중", generating: "생성하는 중",
"upload-pending": "업로드를 기다리는 중",
uploading: "업로드하는 중", uploading: "업로드하는 중",
error: "실패", error: "실패",
} as const; } as const;

View File

@@ -1,17 +1,15 @@
import { limitFunction } from "p-limit"; import { limitFunction } from "p-limit";
import { SvelteMap } from "svelte/reactivity"; import { SvelteMap } from "svelte/reactivity";
import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file"; import { storeFileThumbnailCache } from "$lib/modules/file";
import type { FileInfo } from "$lib/modules/filesystem"; import type { FileInfo } from "$lib/modules/filesystem";
import { Scheduler } from "$lib/modules/scheduler"; import { Scheduler } from "$lib/modules/scheduler";
import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail"; import { generateThumbnail } from "$lib/modules/thumbnail";
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file"; import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
export type GenerationStatus = export type GenerationStatus =
| "queued" | "queued"
| "generation-pending" | "generation-pending"
| "generating" | "generating"
| "upload-pending"
| "uploading" | "uploading"
| "uploaded" | "uploaded"
| "error"; | "error";
@@ -31,33 +29,27 @@ export const clearThumbnailGenerationStatuses = () => {
} }
}; };
const generateThumbnail = limitFunction(
async (fileId: number, fileBuffer: ArrayBuffer, fileType: string, dataKey: CryptoKey) => {
statuses.set(fileId, "generating");
const thumbnail = await doGenerateThumbnail(fileBuffer, fileType);
if (!thumbnail) return null;
const thumbnailBuffer = await thumbnail.arrayBuffer();
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
statuses.set(fileId, "upload-pending");
return { plaintext: thumbnailBuffer, ...thumbnailEncrypted };
},
{ concurrency: 4 },
);
const requestThumbnailUpload = limitFunction( const requestThumbnailUpload = limitFunction(
async ( async (fileInfo: FileInfo, fileBuffer: ArrayBuffer) => {
fileId: number, statuses.set(fileInfo.id, "generating");
dataKeyVersion: Date,
thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: ArrayBuffer },
) => {
statuses.set(fileId, "uploading");
const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnail); const thumbnail = await generateThumbnail(
if (!res.ok) return false; new Blob([fileBuffer], { type: fileInfo.contentType }),
statuses.set(fileId, "uploaded"); );
storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended if (!thumbnail) return false;
statuses.set(fileInfo.id, "uploading");
const res = await requestFileThumbnailUpload(
fileInfo.id,
thumbnail,
fileInfo.dataKey?.key!,
fileInfo.dataKey?.version!,
);
if (!res) return false;
statuses.set(fileInfo.id, "uploaded");
void thumbnail.arrayBuffer().then((buffer) => storeFileThumbnailCache(fileInfo.id, buffer));
return true; return true;
}, },
{ concurrency: 4 }, { concurrency: 4 },
@@ -81,16 +73,7 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
return file.byteLength; return file.byteLength;
}, },
async () => { async () => {
const thumbnail = await generateThumbnail( if (!(await requestThumbnailUpload(fileInfo, file!))) {
fileInfo.id,
file!,
fileInfo.contentType,
fileInfo.dataKey?.key!,
);
if (
!thumbnail ||
!(await requestThumbnailUpload(fileInfo.id, fileInfo.dataKey?.version!, thumbnail))
) {
statuses.set(fileInfo.id, "error"); statuses.set(fileInfo.id, "error");
} }
}, },

View File

@@ -1,6 +1,6 @@
import { error, text } from "@sveltejs/kit"; import { error, text } from "@sveltejs/kit";
import { Readable } from "stream"; import { Readable } from "stream";
import { ReadableStream } from "stream/web"; import type { ReadableStream } from "stream/web";
import { z } from "zod"; import { z } from "zod";
import { parseContentDigestHeader } from "$lib/modules/http"; import { parseContentDigestHeader } from "$lib/modules/http";
import { authorize } from "$lib/server/modules/auth"; import { authorize } from "$lib/server/modules/auth";
@@ -13,7 +13,7 @@ export const POST: RequestHandler = async ({ locals, params, request }) => {
const zodRes = z const zodRes = z
.object({ .object({
id: z.uuidv4(), id: z.uuidv4(),
index: z.coerce.number().int().nonnegative(), index: z.coerce.number().int().positive(),
}) })
.safeParse(params); .safeParse(params);
if (!zodRes.success) error(400, "Invalid path parameters"); if (!zodRes.success) error(400, "Invalid path parameters");

View File

@@ -0,0 +1 @@
export * from "../lib/constants";

View File

@@ -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 { decryptChunk, getEncryptedRange, getDecryptedSize } from "../modules/crypto";
import { parseRangeHeader, getContentRangeHeader } from "../modules/http"; import { parseRangeHeader, getContentRangeHeader } from "../modules/http";
import { getFile } from "../modules/opfs"; import { getFile } from "../modules/opfs";
@@ -15,10 +15,13 @@ const createResponse = (
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",
"Content-Length": String(range.end - range.start + 1), "Content-Length": String(range.end - range.start + 1),
"Content-Type": contentType ?? "application/octet-stream",
...(isRangeRequest ? getContentRangeHeader(range) : {}), ...(isRangeRequest ? getContentRangeHeader(range) : {}),
}; };
if (contentType) {
headers["Content-Type"] = contentType;
}
if (downloadFilename) { if (downloadFilename) {
headers["Content-Disposition"] = headers["Content-Disposition"] =
`attachment; filename*=UTF-8''${encodeURIComponent(downloadFilename)}`; `attachment; filename*=UTF-8''${encodeURIComponent(downloadFilename)}`;

View File

@@ -3,7 +3,7 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
/// <reference types="@sveltejs/kit" /> /// <reference types="@sveltejs/kit" />
import { DECRYPTED_FILE_URL_PREFIX } from "./modules/constants"; import { DECRYPTED_FILE_URL_PREFIX } from "./constants";
import { decryptFile } from "./handlers"; import { decryptFile } from "./handlers";
import { fileMetadataStore } from "./stores"; import { fileMetadataStore } from "./stores";
import type { ServiceWorkerMessage, ServiceWorkerResponse } from "./types"; import type { ServiceWorkerMessage, ServiceWorkerResponse } from "./types";

View File

@@ -1 +0,0 @@
export * from "../../lib/constants";

View File

@@ -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"; export * from "../../lib/modules/crypto";

View File

@@ -1,7 +1,7 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { createReadStream, createWriteStream } from "fs"; import { createReadStream, createWriteStream } from "fs";
import { mkdir, rename } from "fs/promises"; import { copyFile, mkdir } from "fs/promises";
import mime from "mime"; import mime from "mime";
import { dirname } from "path"; import { dirname } from "path";
import { v4 as uuidv4 } from "uuid"; 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 { safeRecursiveRm, safeUnlink } from "$lib/server/modules/filesystem";
import { router, roleProcedure } from "../init.server"; import { router, roleProcedure } from "../init.server";
const UPLOADS_EXPIRES = 24 * 3600 * 1000; // 24 hours
const sessionLocks = new Set<string>(); const sessionLocks = new Set<string>();
const generateSessionId = async () => { const generateSessionId = async () => {
@@ -60,7 +62,7 @@ const uploadRouter = router({
userId: ctx.session.userId, userId: ctx.session.userId,
path, path,
totalChunks: input.chunks, totalChunks: input.chunks,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours expiresAt: new Date(Date.now() + UPLOADS_EXPIRES),
parentId: input.parent, parentId: input.parent,
mekVersion: input.mekVersion, mekVersion: input.mekVersion,
encDek: input.dek, 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"] completeFileUpload: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
@@ -143,14 +110,14 @@ const uploadRouter = router({
try { try {
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId); 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" }); throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
} else if ( } else if (
(session.hskVersion && !input.contentHmac) || (session.hskVersion && !input.contentHmac) ||
(!session.hskVersion && input.contentHmac) (!session.hskVersion && input.contentHmac)
) { ) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid content HMAC" }); throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid content HMAC" });
} else if (session.uploadedChunks.length < session.totalChunks) { } else if (session.uploadedChunks < session.totalChunks) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" }); throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
} }
@@ -160,7 +127,7 @@ const uploadRouter = router({
const hashStream = createHash("sha256"); const hashStream = createHash("sha256");
const writeStream = createWriteStream(filePath, { flags: "wx", mode: 0o600 }); const writeStream = createWriteStream(filePath, { flags: "wx", mode: 0o600 });
for (let i = 0; i < session.totalChunks; i++) { for (let i = 1; i <= session.totalChunks; i++) {
for await (const chunk of createReadStream(`${session.path}/${i}`)) { for await (const chunk of createReadStream(`${session.path}/${i}`)) {
hashStream.update(chunk); hashStream.update(chunk);
writeStream.write(chunk); writeStream.write(chunk);
@@ -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"] completeFileThumbnailUpload: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
@@ -213,15 +216,15 @@ const uploadRouter = router({
try { try {
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId); 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" }); throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
} else if (session.uploadedChunks.length < session.totalChunks) { } else if (session.uploadedChunks < session.totalChunks) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" }); throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
} }
thumbnailPath = `${env.thumbnailsPath}/${ctx.session.userId}/${uploadId}`; thumbnailPath = `${env.thumbnailsPath}/${ctx.session.userId}/${uploadId}`;
await mkdir(dirname(thumbnailPath), { recursive: true }); await mkdir(dirname(thumbnailPath), { recursive: true });
await rename(`${session.path}/0`, thumbnailPath); await copyFile(`${session.path}/1`, thumbnailPath);
const oldThumbnailPath = await db.transaction().execute(async (trx) => { const oldThumbnailPath = await db.transaction().execute(async (trx) => {
const oldPath = await MediaRepo.updateFileThumbnail( const oldPath = await MediaRepo.updateFileThumbnail(
@@ -238,12 +241,10 @@ const uploadRouter = router({
await Promise.all([safeUnlink(oldThumbnailPath), safeRecursiveRm(session.path)]); await Promise.all([safeUnlink(oldThumbnailPath), safeRecursiveRm(session.path)]);
} catch (e) { } catch (e) {
await safeUnlink(thumbnailPath); await safeUnlink(thumbnailPath);
if (e instanceof IntegrityError) {
if (e.message === "File not found") { if (e instanceof IntegrityError && e.message === "Invalid DEK version") {
throw new TRPCError({ code: "NOT_FOUND", message: "File not found" }); // DEK rotated after this upload started
} else if (e.message === "Invalid DEK version") { throw new TRPCError({ code: "CONFLICT", message: e.message });
throw new TRPCError({ code: "BAD_REQUEST", message: "Mismatched DEK version" });
}
} }
throw e; throw e;
} finally { } finally {
@@ -256,19 +257,22 @@ const uploadRouter = router({
z.object({ z.object({
file: z.int().positive(), file: z.int().positive(),
chunks: z.int().positive(), chunks: z.int().positive(),
dekVersion: z.date(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { id, path } = await generateSessionId(); const { id, path } = await generateSessionId();
try { try {
await UploadRepo.createMigrationUploadSession({ await UploadRepo.createThumbnailOrMigrationUploadSession({
id, id,
type: "migration",
userId: ctx.session.userId, userId: ctx.session.userId,
path, path,
totalChunks: input.chunks, totalChunks: input.chunks,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours expiresAt: new Date(Date.now() + UPLOADS_EXPIRES),
fileId: input.file, fileId: input.file,
dekVersion: input.dekVersion,
}); });
return { uploadId: id }; return { uploadId: id };
} catch (e) { } catch (e) {
@@ -276,9 +280,9 @@ const uploadRouter = router({
if (e instanceof IntegrityError) { if (e instanceof IntegrityError) {
if (e.message === "File not found") { 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") { } 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; throw e;
@@ -303,9 +307,9 @@ const uploadRouter = router({
try { try {
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId); 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" }); throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
} else if (session.uploadedChunks.length < session.totalChunks) { } else if (session.uploadedChunks < session.totalChunks) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" }); throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
} }
@@ -315,7 +319,7 @@ const uploadRouter = router({
const hashStream = createHash("sha256"); const hashStream = createHash("sha256");
const writeStream = createWriteStream(filePath, { flags: "wx", mode: 0o600 }); const writeStream = createWriteStream(filePath, { flags: "wx", mode: 0o600 });
for (let i = 0; i < session.totalChunks; i++) { for (let i = 1; i <= session.totalChunks; i++) {
for await (const chunk of createReadStream(`${session.path}/${i}`)) { for await (const chunk of createReadStream(`${session.path}/${i}`)) {
hashStream.update(chunk); hashStream.update(chunk);
writeStream.write(chunk); writeStream.write(chunk);
@@ -328,11 +332,12 @@ const uploadRouter = router({
const hash = hashStream.digest("base64"); const hash = hashStream.digest("base64");
const oldPath = await db.transaction().execute(async (trx) => { const oldPath = await db.transaction().execute(async (trx) => {
const oldPath = await FileRepo.migrateFileContent( const { oldPath } = await FileRepo.migrateFileContent(
trx, trx,
ctx.session.userId, ctx.session.userId,
session.fileId, session.fileId,
filePath, filePath,
session.dekVersion!,
hash, hash,
); );
await UploadRepo.deleteUploadSession(trx, uploadId); await UploadRepo.deleteUploadSession(trx, uploadId);
@@ -342,12 +347,10 @@ const uploadRouter = router({
await Promise.all([safeUnlink(oldPath), safeRecursiveRm(session.path)]); await Promise.all([safeUnlink(oldPath), safeRecursiveRm(session.path)]);
} catch (e) { } catch (e) {
await safeUnlink(filePath); await safeUnlink(filePath);
if (e instanceof IntegrityError) {
if (e.message === "File not found") { if (e instanceof IntegrityError && e.message === "File is not legacy") {
throw new TRPCError({ code: "NOT_FOUND", message: "File not found" }); // File migrated after this upload started
} else if (e.message === "File is not legacy") { throw new TRPCError({ code: "CONFLICT", message: e.message });
throw new TRPCError({ code: "BAD_REQUEST", message: "File is not legacy" });
}
} }
throw e; throw e;
} finally { } finally {

25
src/workers/hmac.ts Normal file
View File

@@ -0,0 +1,25 @@
import { hmac } from "@noble/hashes/hmac.js";
import { sha256 } from "@noble/hashes/sha2.js";
export interface ComputeMessage {
stream: ReadableStream;
key: Uint8Array;
}
export interface ResultMessage {
result: Uint8Array;
}
self.onmessage = async (event: MessageEvent<ComputeMessage>) => {
const h = hmac.create(sha256, event.data.key);
const reader = event.data.stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
h.update(value);
}
const result = h.digest();
self.postMessage({ result } satisfies ResultMessage, { transfer: [result.buffer] });
};

View File

@@ -8,6 +8,7 @@ const config = {
adapter: adapter(), adapter: adapter(),
alias: { alias: {
$trpc: "./src/trpc", $trpc: "./src/trpc",
$workers: "./src/workers",
}, },
}, },
}; };