상위 디렉터리 및 카테고리의 소유권을 검증하지 않던 문제 등 버그 수정 및 Vitest 기반 테스트 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-11 23:45:56 +09:00
1 parent 67989f4741
commit ed732be999
31 files changed
+2057 -42

No files matched your search

+3
View File
@@ -6,6 +6,7 @@ import {
getHmacSecrets,
} from "#lib/indexedDB/index.js";
import { prepareFileCache } from "#lib/modules/file/index.js";
import { listenForMetadataRequests } from "#lib/serviceWorker/index.js";
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "#lib/stores/index.js";
const requestPersistentStorage = async () => {
@@ -44,6 +45,8 @@ const prepareHmacSecretStore = async () => {
};
export const init: ClientInit = async () => {
listenForMetadataRequests();
await Promise.all([
requestPersistentStorage(),
prepareFileCache(),
+2 -2
View File
@@ -10,11 +10,11 @@ export const decodeString = (data: ArrayBuffer) => {
};
export const encodeToBase64 = (data: ArrayBuffer | Uint8Array) => {
return btoa(String.fromCharCode(...(data instanceof ArrayBuffer ? new Uint8Array(data) : data)));
return (data instanceof ArrayBuffer ? new Uint8Array(data) : data).toBase64();
};
export const decodeFromBase64 = (data: string) => {
return Uint8Array.from(atob(data), (c) => c.charCodeAt(0)).buffer;
return Uint8Array.fromBase64(data).buffer;
};
export const concatenateBuffers = (...buffers: ArrayBuffer[]) => {
+22
View File
@@ -14,6 +14,26 @@ interface Category {
export type NewCategory = Omit<Category, "id">;
export const ensureCategoryExists = async (
trx: typeof db,
userId: number,
categoryId: CategoryId,
) => {
if (categoryId === "root") return;
const category = await trx
.selectFrom("category")
.select("id")
.where("id", "=", categoryId)
.where("user_id", "=", userId)
.limit(1)
.forShare()
.executeTakeFirst();
if (!category) {
throw new IntegrityError("Category not found");
}
};
export const registerCategory = async (params: NewCategory) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
@@ -28,6 +48,8 @@ export const registerCategory = async (params: NewCategory) => {
throw new IntegrityError("Inactive MEK version");
}
await ensureCategoryExists(trx, params.userId, params.parentId);
const { categoryId } = await trx
.insertInto("category")
.values({
+22
View File
@@ -25,6 +25,26 @@ const toDirectory = (row: Selectable<DirectoryTable>): Directory => ({
isFavorite: row.is_favorite,
});
export const ensureDirectoryExists = async (
trx: typeof db,
userId: number,
directoryId: DirectoryId,
) => {
if (directoryId === "root") return;
const directory = await trx
.selectFrom("directory")
.select("id")
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.limit(1)
.forShare()
.executeTakeFirst();
if (!directory) {
throw new IntegrityError("Directory not found");
}
};
export const registerDirectory = async (params: Omit<Directory, "id" | "isFavorite">) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
@@ -39,6 +59,8 @@ export const registerDirectory = async (params: Omit<Directory, "id" | "isFavori
throw new IntegrityError("Inactive MEK version");
}
await ensureDirectoryExists(trx, params.userId, params.parentId);
const { directoryId } = await trx
.insertInto("directory")
.values({
+3
View File
@@ -1,5 +1,6 @@
import { sql } from "kysely";
import { IntegrityError } from "./error";
import { ensureDirectoryExists } from "./directory";
import db from "./kysely";
import type { Ciphertext } from "./schema";
@@ -62,6 +63,8 @@ export const createFileUploadSession = async (
}
}
await ensureDirectoryExists(trx, params.userId, params.parentId);
await trx
.insertInto("upload_session")
.values({
+2 -1
View File
@@ -4,7 +4,7 @@ import { stat } from "fs/promises";
import { Readable } from "stream";
import { FileRepo, MediaRepo } from "#lib/server/db/index.js";
const createEncContentStream = async (
export const createEncContentStream = async (
path: string,
iv?: Buffer,
range?: { start?: number; end?: number },
@@ -36,6 +36,7 @@ const createEncContentStream = async (
),
),
range: { start, end, total: totalSize },
isRangeRequest: !!range,
};
};
+1 -1
View File
@@ -7,7 +7,7 @@ import { safeUnlink } from "#lib/server/modules/filesystem.js";
const chunkLocks = new Set<string>();
const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => {
export const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => {
chunkIndex -= 1;
const byte = bitmap[Math.floor(chunkIndex / 8)];
return !!byte && (byte & (1 << (chunkIndex % 8))) !== 0; // Postgres sucks
+22
View File
@@ -3,6 +3,25 @@ import type { FileMetadata, ServiceWorkerMessage, ServiceWorkerResponse } from "
const PREPARE_TIMEOUT_MS = 5000;
// The service worker may be terminated at any time, losing its in-memory metadata.
// Keep a copy here so that it can be re-requested on demand.
const preparedFiles = new Map<number, FileMetadata>();
let isListeningForMetadataRequests = false;
export const listenForMetadataRequests = () => {
if (isListeningForMetadataRequests) return;
isListeningForMetadataRequests = true;
navigator.serviceWorker.addEventListener(
"message",
(event: MessageEvent<ServiceWorkerResponse>) => {
if (event.data.type === "decryption-metadata-request") {
event.ports[0]?.postMessage(preparedFiles.get(event.data.fileId) ?? null);
}
},
);
};
const getServiceWorker = async () => {
const registration = await navigator.serviceWorker.ready;
const sw = registration.active;
@@ -13,6 +32,9 @@ const getServiceWorker = async () => {
};
export const prepareFileDecryption = async (id: number, metadata: FileMetadata) => {
preparedFiles.set(id, metadata);
listenForMetadataRequests();
const sw = await getServiceWorker();
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
+7 -1
View File
@@ -15,5 +15,11 @@ export interface DecryptionReadyMessage {
fileId: number;
}
// Sent with a MessagePort to which the client replies with `FileMetadata | null`
export interface DecryptionMetadataRequestMessage {
type: "decryption-metadata-request";
fileId: number;
}
export type ServiceWorkerMessage = DecryptionPrepareMessage;
export type ServiceWorkerResponse = DecryptionReadyMessage;
export type ServiceWorkerResponse = DecryptionReadyMessage | DecryptionMetadataRequestMessage;
@@ -17,19 +17,19 @@ const downloadHandler = async (locals: App.Locals, params: RouteParams, request:
const { id } = zodRes.data;
const getStream = params.thumbnail ? getFileThumbnailStream : getFileStream;
const { encContentStream, range } = await getStream(
const { encContentStream, range, isRangeRequest } = await getStream(
userId,
id,
parseRangeHeader(request.headers.get("Range")),
);
return {
stream: encContentStream,
status: range ? 206 : 200,
status: isRangeRequest ? 206 : 200,
headers: {
"Accept-Ranges": "bytes",
"Content-Length": String(range.end - range.start + 1),
"Content-Type": "application/octet-stream",
...getContentRangeHeader(range),
...(isRangeRequest ? getContentRangeHeader(range) : {}),
},
};
};
+4 -4
View File
@@ -1,8 +1,8 @@
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 { getFileMetadata } from "../modules/metadata";
import { getFile } from "../modules/opfs";
import { fileMetadataStore } from "../stores";
import type { FileMetadata } from "../types";
const createResponse = (
@@ -131,15 +131,15 @@ const streamFromServer = async (
);
};
const decryptFileHandler = async (request: Request) => {
const decryptFileHandler = async (request: Request, clientId: string) => {
const url = new URL(request.url);
const fileId = parseInt(url.pathname.slice(DECRYPTED_FILE_URL_PREFIX.length), 10);
if (isNaN(fileId)) {
throw new Response("Invalid file id", { status: 400 });
return new Response("Invalid file id", { status: 400 });
}
const downloadFilename = url.searchParams.get("download") ?? undefined;
const metadata = fileMetadataStore.get(fileId);
const metadata = await getFileMetadata(fileId, clientId);
const range = parseRangeHeader(request.headers.get("Range"));
const cache = await getFile(`/cache/${fileId}`);
if (cache) {
+1 -1
View File
@@ -24,7 +24,7 @@ self.addEventListener("message", (event) => {
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith(DECRYPTED_FILE_URL_PREFIX)) {
event.respondWith(decryptFile(event.request));
event.respondWith(decryptFile(event.request, event.clientId));
}
});
+46
View File
@@ -0,0 +1,46 @@
import { self } from "$app/service-worker";
import { fileMetadataStore } from "../stores";
import type { FileMetadata, ServiceWorkerResponse } from "../types";
const METADATA_REQUEST_TIMEOUT_MS = 3000;
const requestFromClient = (client: Client, fileId: number) => {
return new Promise<FileMetadata | null>((resolve) => {
const channel = new MessageChannel();
const timeout = setTimeout(() => {
channel.port1.close();
resolve(null);
}, METADATA_REQUEST_TIMEOUT_MS);
channel.port1.onmessage = (event: MessageEvent<FileMetadata | null>) => {
clearTimeout(timeout);
channel.port1.close();
resolve(event.data);
};
client.postMessage(
{ type: "decryption-metadata-request", fileId } satisfies ServiceWorkerResponse,
[channel.port2],
);
});
};
export const getFileMetadata = async (fileId: number, clientId: string) => {
const cached = fileMetadataStore.get(fileId);
if (cached) return cached;
// The worker might have been restarted since the client prepared the decryption
const requestingClient = clientId ? await self.clients.get(clientId) : undefined;
const windowClients = await self.clients.matchAll({ type: "window" });
const candidates = [
...(requestingClient ? [requestingClient] : []),
...windowClients.filter(({ id }) => id !== requestingClient?.id),
];
for (const client of candidates) {
const metadata = await requestFromClient(client, fileId);
if (metadata) {
fileMetadataStore.set(fileId, metadata);
return metadata;
}
}
};
+23 -1
View File
@@ -1,3 +1,25 @@
import type { FileMetadata } from "./types";
export const fileMetadataStore = new Map<number, FileMetadata>();
const MAX_FILE_METADATA_ENTRIES = 32;
const fileMetadata = new Map<number, FileMetadata>();
export const fileMetadataStore = {
get(fileId: number) {
const metadata = fileMetadata.get(fileId);
if (metadata) {
// Move to the end to keep the most recently used entries
fileMetadata.delete(fileId);
fileMetadata.set(fileId, metadata);
}
return metadata;
},
set(fileId: number, metadata: FileMetadata) {
fileMetadata.delete(fileId);
fileMetadata.set(fileId, metadata);
while (fileMetadata.size > MAX_FILE_METADATA_ENTRIES) {
fileMetadata.delete(fileMetadata.keys().next().value!);
}
},
};
+6 -2
View File
@@ -91,8 +91,12 @@ const categoryRouter = router({
encName: { ciphertext: input.name, iv: input.nameIv },
});
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
if (e instanceof IntegrityError) {
if (e.message === "Inactive MEK version") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
} else if (e.message === "Category not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid parent category id" });
}
}
throw e;
}
+6 -2
View File
@@ -89,8 +89,12 @@ const directoryRouter = router({
encName: { ciphertext: input.name, iv: input.nameIv },
});
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
if (e instanceof IntegrityError) {
if (e.message === "Inactive MEK version") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
} else if (e.message === "Directory not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid parent directory id" });
}
}
throw e;
}
+2
View File
@@ -98,6 +98,8 @@ const uploadRouter = router({
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid MEK version" });
} else if (e.message === "Inactive HSK version") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid HSK version" });
} else if (e.message === "Directory not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid parent directory id" });
}
}
throw e;