파일 업로드 방식을 Chunking 방식으로 변경

This commit is contained in:
static
2026-01-11 04:45:21 +09:00
parent b9e6f17b0c
commit 4b783a36e9
30 changed files with 667 additions and 315 deletions

View File

@@ -15,8 +15,6 @@ interface Directory {
encName: Ciphertext;
}
export type NewDirectory = Omit<Directory, "id">;
interface File {
id: number;
parentId: DirectoryId;
@@ -28,15 +26,13 @@ interface File {
hskVersion: number | null;
contentHmac: string | null;
contentType: string;
encContentIv: string;
encContentIv: string | null;
encContentHash: string;
encName: Ciphertext;
encCreatedAt: Ciphertext | null;
encLastModifiedAt: Ciphertext;
}
export type NewFile = Omit<File, "id">;
interface FileCategory {
id: number;
parentId: CategoryId;
@@ -46,7 +42,7 @@ interface FileCategory {
encName: Ciphertext;
}
export const registerDirectory = async (params: NewDirectory) => {
export const registerDirectory = async (params: Omit<Directory, "id">) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
@@ -214,69 +210,41 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
});
};
export const registerFile = async (params: NewFile) => {
export const registerFile = async (trx: typeof db, params: Omit<File, "id">) => {
if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) {
throw new Error("Invalid arguments");
}
return await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (mek?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
if (params.hskVersion) {
const hsk = await trx
.selectFrom("hmac_secret_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (hsk?.version !== params.hskVersion) {
throw new IntegrityError("Inactive HSK version");
}
}
const { fileId } = await trx
.insertInto("file")
.values({
parent_id: params.parentId !== "root" ? params.parentId : null,
user_id: params.userId,
path: params.path,
master_encryption_key_version: params.mekVersion,
encrypted_data_encryption_key: params.encDek,
data_encryption_key_version: params.dekVersion,
hmac_secret_key_version: params.hskVersion,
content_hmac: params.contentHmac,
content_type: params.contentType,
encrypted_content_iv: params.encContentIv,
encrypted_content_hash: params.encContentHash,
encrypted_name: params.encName,
encrypted_created_at: params.encCreatedAt,
encrypted_last_modified_at: params.encLastModifiedAt,
})
.returning("id as fileId")
.executeTakeFirstOrThrow();
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: "create",
new_name: params.encName,
})
.execute();
return { id: fileId };
});
const { fileId } = await trx
.insertInto("file")
.values({
parent_id: params.parentId !== "root" ? params.parentId : null,
user_id: params.userId,
path: params.path,
master_encryption_key_version: params.mekVersion,
encrypted_data_encryption_key: params.encDek,
data_encryption_key_version: params.dekVersion,
hmac_secret_key_version: params.hskVersion,
content_hmac: params.contentHmac,
content_type: params.contentType,
encrypted_content_iv: params.encContentIv,
encrypted_content_hash: params.encContentHash,
encrypted_name: params.encName,
encrypted_created_at: params.encCreatedAt,
encrypted_last_modified_at: params.encLastModifiedAt,
})
.returning("id as fileId")
.executeTakeFirstOrThrow();
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: "create",
new_name: params.encName,
})
.execute();
return { id: fileId };
};
export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => {

View File

@@ -5,6 +5,7 @@ export * as HskRepo from "./hsk";
export * as MediaRepo from "./media";
export * as MekRepo from "./mek";
export * as SessionRepo from "./session";
export * as UploadRepo from "./upload";
export * as UserRepo from "./user";
export * from "./error";

View File

@@ -0,0 +1,50 @@
import { Kysely, sql } from "kysely";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const up = async (db: Kysely<any>) => {
// file.ts
await db.schema
.alterTable("file")
.alterColumn("encrypted_content_iv", (col) => col.dropNotNull())
.execute();
// upload.ts
await db.schema
.createTable("upload_session")
.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("total_chunks", "integer", (col) => col.notNull())
.addColumn("uploaded_chunks", sql`integer[]`, (col) => col.notNull().defaultTo(sql`'{}'`))
.addColumn("expires_at", "timestamp(3)", (col) => col.notNull())
.addColumn("parent_id", "integer", (col) => col.references("directory.id"))
.addColumn("master_encryption_key_version", "integer", (col) => col.notNull())
.addColumn("encrypted_data_encryption_key", "text", (col) => col.notNull())
.addColumn("data_encryption_key_version", "timestamp(3)", (col) => col.notNull())
.addColumn("hmac_secret_key_version", "integer")
.addColumn("content_type", "text", (col) => col.notNull())
.addColumn("encrypted_name", "json", (col) => col.notNull())
.addColumn("encrypted_created_at", "json")
.addColumn("encrypted_last_modified_at", "json", (col) => col.notNull())
.addForeignKeyConstraint(
"upload_session_fk01",
["user_id", "master_encryption_key_version"],
"master_encryption_key",
["user_id", "version"],
)
.addForeignKeyConstraint(
"upload_session_fk02",
["user_id", "hmac_secret_key_version"],
"hmac_secret_key",
["user_id", "version"],
)
.execute();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const down = async (db: Kysely<any>) => {
await db.schema.dropTable("upload_session").execute();
await db.schema
.alterTable("file")
.alterColumn("encrypted_content_iv", (col) => col.setNotNull())
.execute();
};

View File

@@ -1,9 +1,11 @@
import * as Initial1737357000 from "./1737357000-Initial";
import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory";
import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail";
import * as AddChunkedUpload1768062380 from "./1768062380-AddChunkedUpload";
export default {
"1737357000-Initial": Initial1737357000,
"1737422340-AddFileCategory": AddFileCategory1737422340,
"1738409340-AddThumbnail": AddThumbnail1738409340,
"1768062380-AddChunkedUpload": AddChunkedUpload1768062380,
};

View File

@@ -30,7 +30,7 @@ interface FileTable {
hmac_secret_key_version: number | null;
content_hmac: string | null; // Base64
content_type: string;
encrypted_content_iv: string; // Base64
encrypted_content_iv: string | null; // Base64
encrypted_content_hash: string; // Base64
encrypted_name: Ciphertext;
encrypted_created_at: Ciphertext | null;

View File

@@ -5,6 +5,7 @@ export * from "./hsk";
export * from "./media";
export * from "./mek";
export * from "./session";
export * from "./upload";
export * from "./user";
export * from "./util";

View File

@@ -0,0 +1,26 @@
import type { Generated } from "kysely";
import type { Ciphertext } from "./util";
interface UploadSessionTable {
id: Generated<string>;
user_id: number;
total_chunks: number;
uploaded_chunks: Generated<number[]>;
expires_at: Date;
parent_id: number | null;
master_encryption_key_version: number;
encrypted_data_encryption_key: string; // Base64
data_encryption_key_version: Date;
hmac_secret_key_version: number | null;
content_type: string;
encrypted_name: Ciphertext;
encrypted_created_at: Ciphertext | null;
encrypted_last_modified_at: Ciphertext;
}
declare module "./index" {
interface Database {
upload_session: UploadSessionTable;
}
}

122
src/lib/server/db/upload.ts Normal file
View File

@@ -0,0 +1,122 @@
import { sql } from "kysely";
import { IntegrityError } from "./error";
import db from "./kysely";
import type { Ciphertext } from "./schema";
interface UploadSession {
id: string;
userId: number;
totalChunks: number;
uploadedChunks: number[];
expiresAt: Date;
parentId: DirectoryId;
mekVersion: number;
encDek: string;
dekVersion: Date;
hskVersion: number | null;
contentType: string;
encName: Ciphertext;
encCreatedAt: Ciphertext | null;
encLastModifiedAt: Ciphertext;
}
export const createUploadSession = async (params: Omit<UploadSession, "id" | "uploadedChunks">) => {
return await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (mek?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
if (params.hskVersion) {
const hsk = await trx
.selectFrom("hmac_secret_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (hsk?.version !== params.hskVersion) {
throw new IntegrityError("Inactive HSK version");
}
}
const { sessionId } = await trx
.insertInto("upload_session")
.values({
user_id: params.userId,
total_chunks: params.totalChunks,
expires_at: params.expiresAt,
parent_id: params.parentId !== "root" ? params.parentId : null,
master_encryption_key_version: params.mekVersion,
encrypted_data_encryption_key: params.encDek,
data_encryption_key_version: params.dekVersion,
hmac_secret_key_version: params.hskVersion,
content_type: params.contentType,
encrypted_name: params.encName,
encrypted_created_at: params.encCreatedAt,
encrypted_last_modified_at: params.encLastModifiedAt,
})
.returning("id as sessionId")
.executeTakeFirstOrThrow();
return { id: sessionId };
});
};
export const getUploadSession = async (sessionId: string, userId: number) => {
const session = await db
.selectFrom("upload_session")
.selectAll()
.where("id", "=", sessionId)
.where("user_id", "=", userId)
.where("expires_at", ">", new Date())
.limit(1)
.executeTakeFirst();
return session
? ({
id: session.id,
userId: session.user_id,
totalChunks: session.total_chunks,
uploadedChunks: session.uploaded_chunks,
expiresAt: session.expires_at,
parentId: session.parent_id ?? "root",
mekVersion: session.master_encryption_key_version,
encDek: session.encrypted_data_encryption_key,
dekVersion: session.data_encryption_key_version,
hskVersion: session.hmac_secret_key_version,
contentType: session.content_type,
encName: session.encrypted_name,
encCreatedAt: session.encrypted_created_at,
encLastModifiedAt: session.encrypted_last_modified_at,
} satisfies UploadSession)
: null;
};
export const markChunkAsUploaded = async (sessionId: string, chunkIndex: number) => {
await db
.updateTable("upload_session")
.set({ uploaded_chunks: sql`array_append(uploaded_chunks, ${chunkIndex})` })
.where("id", "=", sessionId)
.execute();
};
export const deleteUploadSession = async (trx: typeof db, sessionId: string) => {
await trx.deleteFrom("upload_session").where("id", "=", sessionId).execute();
};
export const cleanupExpiredUploadSessions = async () => {
const sessions = await db
.deleteFrom("upload_session")
.where("expires_at", "<", new Date())
.returning("id")
.execute();
return sessions.map(({ id }) => id);
};

View File

@@ -26,4 +26,5 @@ export default {
},
libraryPath: env.LIBRARY_PATH || "library",
thumbnailsPath: env.THUMBNAILS_PATH || "thumbnails",
uploadsPath: env.UPLOADS_PATH || "uploads",
};

View File

@@ -1,4 +1,7 @@
import { unlink } from "fs/promises";
import env from "$lib/server/loadenv";
export const getChunkDirectoryPath = (sessionId: string) => `${env.uploadsPath}/${sessionId}`;
export const safeUnlink = async (path: string | null | undefined) => {
if (path) {

View File

@@ -1,36 +1,7 @@
import mime from "mime";
import { z } from "zod";
import { directoryIdSchema } from "./directory";
export const fileThumbnailUploadRequest = z.object({
dekVersion: z.iso.datetime(),
contentIv: z.base64().nonempty(),
});
export type FileThumbnailUploadRequest = z.input<typeof fileThumbnailUploadRequest>;
export const fileUploadRequest = z.object({
parent: directoryIdSchema,
mekVersion: z.int().positive(),
dek: z.base64().nonempty(),
dekVersion: z.iso.datetime(),
hskVersion: z.int().positive(),
contentHmac: z.base64().nonempty(),
contentType: z
.string()
.trim()
.nonempty()
.refine((value) => mime.getExtension(value) !== null), // MIME type
contentIv: z.base64().nonempty(),
name: z.base64().nonempty(),
nameIv: z.base64().nonempty(),
createdAt: z.base64().nonempty().optional(),
createdAtIv: z.base64().nonempty().optional(),
lastModifiedAt: z.base64().nonempty(),
lastModifiedAtIv: z.base64().nonempty(),
});
export type FileUploadRequest = z.input<typeof fileUploadRequest>;
export const fileUploadResponse = z.object({
file: z.int().positive(),
});
export type FileUploadResponse = z.output<typeof fileUploadResponse>;

View File

@@ -6,17 +6,20 @@ import { dirname } from "path";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import { v4 as uuidv4 } from "uuid";
import { FileRepo, MediaRepo, IntegrityError } from "$lib/server/db";
import { CHUNK_SIZE, ENCRYPTION_OVERHEAD } from "$lib/constants";
import { FileRepo, MediaRepo, UploadRepo, IntegrityError } from "$lib/server/db";
import env from "$lib/server/loadenv";
import { safeUnlink } from "$lib/server/modules/filesystem";
import { getChunkDirectoryPath, safeUnlink } from "$lib/server/modules/filesystem";
const uploadLocks = new Set<string>();
const createEncContentStream = async (
path: string,
iv: Buffer,
iv?: Buffer,
range?: { start?: number; end?: number },
) => {
const { size: fileSize } = await stat(path);
const ivSize = iv.byteLength;
const ivSize = iv?.byteLength ?? 0;
const totalSize = fileSize + ivSize;
const start = range?.start ?? 0;
@@ -30,7 +33,7 @@ const createEncContentStream = async (
Readable.from(
(async function* () {
if (start < ivSize) {
yield iv.subarray(start, Math.min(end + 1, ivSize));
yield iv!.subarray(start, Math.min(end + 1, ivSize));
}
if (end >= ivSize) {
yield* createReadStream(path, {
@@ -55,7 +58,11 @@ export const getFileStream = async (
error(404, "Invalid file id");
}
return createEncContentStream(file.path, Buffer.from(file.encContentIv, "base64"), range);
return createEncContentStream(
file.path,
file.encContentIv ? Buffer.from(file.encContentIv, "base64") : undefined,
range,
);
};
export const getFileThumbnailStream = async (
@@ -110,56 +117,70 @@ export const uploadFileThumbnail = async (
}
};
export const uploadFile = async (
params: Omit<FileRepo.NewFile, "path" | "encContentHash">,
encContentStream: Readable,
encContentHash: Promise<string>,
export const uploadChunk = async (
userId: number,
sessionId: string,
chunkIndex: number,
encChunkStream: Readable,
encChunkHash: string,
) => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
if (params.dekVersion <= oneDayAgo || params.dekVersion >= oneMinuteLater) {
error(400, "Invalid DEK version");
const lockKey = `${sessionId}/${chunkIndex}`;
if (uploadLocks.has(lockKey)) {
error(409, "Chunk already uploaded"); // TODO: Message
} else {
uploadLocks.add(lockKey);
}
const path = `${env.libraryPath}/${params.userId}/${uuidv4()}`;
await mkdir(dirname(path), { recursive: true });
const filePath = `${getChunkDirectoryPath(sessionId)}/${chunkIndex}`;
try {
const hashStream = createHash("sha256");
const [, hash] = await Promise.all([
pipeline(
encContentStream,
async function* (source) {
for await (const chunk of source) {
hashStream.update(chunk);
yield chunk;
}
},
createWriteStream(path, { flags: "wx", mode: 0o600 }),
),
encContentHash,
]);
if (hashStream.digest("base64") !== hash) {
throw new Error("Invalid checksum");
const session = await UploadRepo.getUploadSession(sessionId, userId);
if (!session) {
error(404, "Invalid upload id");
} else if (chunkIndex >= session.totalChunks) {
error(400, "Invalid chunk index");
} else if (session.uploadedChunks.includes(chunkIndex)) {
error(409, "Chunk already uploaded");
}
const { id: fileId } = await FileRepo.registerFile({
...params,
path,
encContentHash: hash,
});
return { fileId };
} catch (e) {
await safeUnlink(path);
const isLastChunk = chunkIndex === session.totalChunks - 1;
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Invalid MEK version");
let writtenBytes = 0;
const hashStream = createHash("sha256");
const writeStream = createWriteStream(filePath, { flags: "wx", mode: 0o600 });
for await (const chunk of encChunkStream) {
writtenBytes += chunk.length;
hashStream.update(chunk);
writeStream.write(chunk);
}
await new Promise<void>((resolve, reject) => {
writeStream.end((e: any) => (e ? reject(e) : resolve()));
});
if (hashStream.digest("base64") !== encChunkHash) {
throw new Error("Invalid checksum");
} else if (
(!isLastChunk && writtenBytes !== CHUNK_SIZE + ENCRYPTION_OVERHEAD) ||
(isLastChunk &&
(writtenBytes <= ENCRYPTION_OVERHEAD || writtenBytes > CHUNK_SIZE + ENCRYPTION_OVERHEAD))
) {
throw new Error("Invalid chunk size");
}
await UploadRepo.markChunkAsUploaded(sessionId, chunkIndex);
} catch (e) {
await safeUnlink(filePath);
if (
e instanceof Error &&
(e.message === "Invalid request body" || e.message === "Invalid checksum")
(e.message === "Invalid checksum" || e.message === "Invalid chunk size")
) {
error(400, "Invalid request body");
}
throw e;
} finally {
uploadLocks.delete(lockKey);
}
};