형식이 잘못된 세션 쿠키로 인해 서버 오류가 발생하던 문제와 업로드 중 오류가 무시되거나 무한히 대기하던 문제 등 버그 수정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-12 08:19:41 +09:00
1 parent bb0328b39e
commit 9119c21881
11 files changed
+161 -77

No files matched your search

+11 -5
View File
@@ -8,11 +8,17 @@ import { FileDeletionService, UploadService } from "#lib/server/services/index.t
export const init: ServerInit = async () => { export const init: ServerInit = async () => {
await migrateDB(); await migrateDB();
schedule.scheduleJob("0 * * * *", () => { schedule.scheduleJob("0 * * * *", async () => {
ClientRepo.cleanupExpiredUserClientChallenges(); // Failures are logged and retried at the next run, so they don't crash the server
SessionRepo.cleanupExpiredSessions(); const results = await Promise.allSettled([
SessionRepo.cleanupExpiredSessionUpgradeChallenges(); ClientRepo.cleanupExpiredUserClientChallenges(),
UploadService.cleanupExpiredUploadSessions(); SessionRepo.cleanupExpiredSessions(),
SessionRepo.cleanupExpiredSessionUpgradeChallenges(),
UploadService.cleanupExpiredUploadSessions(),
]);
results.forEach((result) => {
if (result.status === "rejected") console.error(result.reason);
});
FileDeletionService.schedulePendingFileDeletions(); FileDeletionService.schedulePendingFileDeletions();
}); });
FileDeletionService.schedulePendingFileDeletions(); FileDeletionService.schedulePendingFileDeletions();
+5 -1
View File
@@ -9,7 +9,11 @@ export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => {
return new Promise<Uint8Array>((resolve, reject) => { return new Promise<Uint8Array>((resolve, reject) => {
worker.onmessage = ({ data }: MessageEvent<ResultMessage>) => { worker.onmessage = ({ data }: MessageEvent<ResultMessage>) => {
resolve(data.result); if ("result" in data) {
resolve(data.result);
} else {
reject(data.error);
}
worker.terminate(); worker.terminate();
}; };
+6 -1
View File
@@ -1,5 +1,7 @@
import ExifReader from "exifreader"; import ExifReader from "exifreader";
const isInteger = (value: number | undefined): value is number => Number.isInteger(value);
export const extractExifDateTime = (fileBuffer: ArrayBuffer) => { export const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
const exif = ExifReader.load(fileBuffer); const exif = ExifReader.load(fileBuffer);
const dateTimeOriginal = exif["DateTimeOriginal"]?.description; const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
@@ -11,7 +13,10 @@ export const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
const [year, month, day] = date.split(":").map(Number); const [year, month, day] = date.split(":").map(Number);
const [hour, minute, second] = time.split(":").map(Number); const [hour, minute, second] = time.split(":").map(Number);
if (!year || !month || !day || !hour || !minute || !second) return undefined; // Hours, minutes, and seconds may be 0, so they are checked differently
if (!year || !month || !day || !isInteger(hour) || !isInteger(minute) || !isInteger(second)) {
return undefined;
}
if (!offsetTimeOriginal) { if (!offsetTimeOriginal) {
// No timezone information.. Assume local timezone // No timezone information.. Assume local timezone
+28 -25
View File
@@ -1,6 +1,5 @@
import { sha256 } from "@noble/hashes/sha2.js"; import { sha256 } from "@noble/hashes/sha2.js";
import axios from "axios"; import axios from "axios";
import pLimit from "p-limit";
import { encodeToBase64, encryptChunk } from "#lib/shared/crypto/index.ts"; import { encodeToBase64, encryptChunk } from "#lib/shared/crypto/index.ts";
import { CHUNK_SIZE, getChunkCount, getEncryptedSize } from "#lib/shared/format/index.ts"; import { CHUNK_SIZE, getChunkCount, getEncryptedSize } from "#lib/shared/format/index.ts";
import { BoundedQueue } from "#lib/utils/concurrency/index.ts"; import { BoundedQueue } from "#lib/utils/concurrency/index.ts";
@@ -70,7 +69,6 @@ export const uploadBlob = async (
}; };
const queue = new BoundedQueue<EncryptedChunk>(maxQueueSize); const queue = new BoundedQueue<EncryptedChunk>(maxQueueSize);
let encryptionError: Error | null = null;
// Producer: encrypt chunks and push to queue // Producer: encrypt chunks and push to queue
const encryptionProducer = async () => { const encryptionProducer = async () => {
@@ -82,8 +80,6 @@ export const uploadBlob = async (
hash.update(new Uint8Array(data)); hash.update(new Uint8Array(data));
await queue.push({ index: i, data }); await queue.push({ index: i, data });
} }
} catch (e) {
encryptionError = e instanceof Error ? e : new Error(String(e));
} finally { } finally {
queue.close(); queue.close();
} }
@@ -91,36 +87,43 @@ export const uploadBlob = async (
// Consumer: upload chunks from queue with concurrency limit // Consumer: upload chunks from queue with concurrency limit
const uploadConsumer = async () => { const uploadConsumer = async () => {
const limit = pLimit(networkConcurrency);
const activeTasks = new Set<Promise<void>>(); const activeTasks = new Set<Promise<void>>();
let failure: { reason: unknown } | undefined;
while (true) { try {
const item = await queue.pop(); while (!failure) {
if (item === null) break; const item = await queue.pop();
if (encryptionError) throw encryptionError; if (item === null) break;
const task = limit(async () => { const task: Promise<void> = uploadEncryptedChunk(
try { uploadId,
await uploadEncryptedChunk(uploadId, item.index, item.data, onChunkProgress); item.index,
} finally { item.data,
// @ts-expect-error onChunkProgress,
item.data = null; )
.catch((reason) => {
failure ??= { reason };
})
.finally(() => activeTasks.delete(task));
activeTasks.add(task);
if (activeTasks.size >= networkConcurrency) {
await Promise.race(activeTasks);
} }
});
activeTasks.add(task);
task.finally(() => activeTasks.delete(task));
if (activeTasks.size >= networkConcurrency) {
await Promise.race(activeTasks);
} }
}
await Promise.all(activeTasks); await Promise.all(activeTasks);
if (failure) throw failure.reason;
} finally {
queue.close(); // Stops the producer if the upload failed
}
}; };
// Run producer and consumer concurrently // Run producer and consumer concurrently
await Promise.all([encryptionProducer(), uploadConsumer()]); const [encryption, upload] = await Promise.allSettled([encryptionProducer(), uploadConsumer()]);
// The upload error takes precedence, as the producer fails due to the closed queue then
if (upload.status === "rejected") throw upload.reason;
if (encryption.status === "rejected") throw encryption.reason;
onProgress?.({ progress: 1, rate: speedMeter() }); onProgress?.({ progress: 1, rate: speedMeter() });
return { encContentHash: encodeToBase64(hash.digest()) }; return { encContentHash: encodeToBase64(hash.digest()) };
+7 -6
View File
@@ -56,12 +56,13 @@ export const issueSessionId = async (length: number, secret: string) => {
export const verifySessionId = (sessionIdSigned: string, secret: string) => { export const verifySessionId = (sessionIdSigned: string, secret: string) => {
const [sessionIdHex, sessionIdHmac] = sessionIdSigned.split("."); const [sessionIdHex, sessionIdHmac] = sessionIdSigned.split(".");
if (!sessionIdHex || !sessionIdHmac) return; if (!sessionIdHex || !sessionIdHmac) return;
if (
timingSafeEqual( const actualHmac = Buffer.from(sessionIdHmac, "hex");
Buffer.from(sessionIdHmac, "hex"), const expectedHmac = createHmac("sha256", secret)
createHmac("sha256", secret).update(Buffer.from(sessionIdHex, "hex")).digest(), .update(Buffer.from(sessionIdHex, "hex"))
) .digest();
) { // `timingSafeEqual` throws if the lengths differ (e.g. malformed hex)
if (actualHmac.length === expectedHmac.length && timingSafeEqual(actualHmac, expectedHmac)) {
return sessionIdHex; return sessionIdHex;
} }
}; };
+5 -5
View File
@@ -24,17 +24,17 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
id="container" data-entry-container
onclick={onclick && (() => setTimeout(onclick, 100))} onclick={onclick && (() => setTimeout(onclick, 100))}
class={["rounded-xl", className]} class={["rounded-xl", className]}
> >
<div id="children" class="flex h-full items-center gap-x-4 p-2 transition"> <div data-entry-content class="flex h-full items-center gap-x-4 p-2 transition">
<div class="grow overflow-x-hidden"> <div class="grow overflow-x-hidden">
{@render children()} {@render children()}
</div> </div>
{#if ActionButtonIcon} {#if ActionButtonIcon}
<button <button
id="action-button" data-action-button
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onActionButtonClick) { if (onActionButtonClick) {
@@ -52,10 +52,10 @@
<style lang="postcss"> <style lang="postcss">
@reference "../../../app.css"; @reference "../../../app.css";
#container:active:not(:has(#action-button:active)) { [data-entry-container]:active:not(:has([data-action-button]:active)) {
@apply bg-gray-100; @apply bg-gray-100;
} }
#children:active:not(:has(#action-button:active)) { [data-entry-content]:active:not(:has([data-action-button]:active)) {
@apply scale-95; @apply scale-95;
} }
</style> </style>
+22 -18
View File
@@ -13,24 +13,28 @@ import {
const uploadRouter = router({ const uploadRouter = router({
startFileUpload: roleProcedure["activeClient"] startFileUpload: roleProcedure["activeClient"]
.input( .input(
z.object({ z
chunks: IdSchema, .object({
parent: DirectoryIdSchema, chunks: IdSchema,
mekVersion: IdSchema, parent: DirectoryIdSchema,
dek: Base64Schema, mekVersion: IdSchema,
dekVersion: z.date(), dek: Base64Schema,
hskVersion: IdSchema.optional(), dekVersion: z.date(),
contentType: z hskVersion: IdSchema.optional(),
.string() contentType: z
.trim() .string()
.nonempty() .trim()
.refine((value) => mime.getExtension(value) !== null), .nonempty()
...EncryptedNameSchema, .refine((value) => mime.getExtension(value) !== null),
createdAt: Base64Schema.optional(), ...EncryptedNameSchema,
createdAtIv: Base64Schema.optional(), createdAt: Base64Schema.optional(),
lastModifiedAt: Base64Schema, createdAtIv: Base64Schema.optional(),
lastModifiedAtIv: Base64Schema, lastModifiedAt: Base64Schema,
}), lastModifiedAtIv: Base64Schema,
})
.refine(({ createdAt, createdAtIv }) => !createdAt === !createdAtIv, {
message: "createdAt and createdAtIv must be provided together",
}),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
return await UploadService.startFileUpload({ return await UploadService.startFileUpload({
+17 -12
View File
@@ -6,20 +6,25 @@ export interface ComputeMessage {
key: Uint8Array; key: Uint8Array;
} }
export interface ResultMessage { // Errors are sent as messages, as rejections in `onmessage` don't fire `error` on the worker
result: Uint8Array; export type ResultMessage = { result: Uint8Array } | { error: Error };
}
self.onmessage = async (event: MessageEvent<ComputeMessage>) => { self.onmessage = async (event: MessageEvent<ComputeMessage>) => {
const h = hmac.create(sha256, event.data.key); try {
const reader = event.data.stream.getReader(); const h = hmac.create(sha256, event.data.key);
const reader = event.data.stream.getReader();
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
h.update(value); h.update(value);
}
const result = h.digest();
self.postMessage({ result } satisfies ResultMessage, { transfer: [result.buffer] });
} catch (e) {
// Only errors are guaranteed to be cloneable
const error = e instanceof Error ? e : new Error(String(e));
self.postMessage({ error } satisfies ResultMessage);
} }
const result = h.digest();
self.postMessage({ result } satisfies ResultMessage, { transfer: [result.buffer] });
}; };
+2 -2
View File
@@ -69,7 +69,7 @@ test("즐겨찾기에 추가하고 즐겨찾기 화면에서 해제한다", asyn
await app.page.goto("/favorites"); await app.page.goto("/favorites");
await expect(app.row(image.name)).toBeVisible(); await expect(app.row(image.name)).toBeVisible();
await app.row(image.name).locator("#action-button").click(); await app.row(image.name).locator("[data-action-button]").click();
await expect(app.row(image.name)).toBeHidden(); await expect(app.row(image.name)).toBeHidden();
await app.page.goto("/directory"); await app.page.goto("/directory");
@@ -105,7 +105,7 @@ test("카테고리를 만들고 파일을 추가, 제거한 뒤 이름을 바꾸
await app.row("여행").click(); await app.row("여행").click();
await page.waitForURL(/\/category\/\d+/); await page.waitForURL(/\/category\/\d+/);
await expect(app.row(image.name)).toBeVisible(); await expect(app.row(image.name)).toBeVisible();
await app.row(image.name).locator("#action-button").click(); await app.row(image.name).locator("[data-action-button]").click();
await expect(app.row(image.name)).toBeHidden(); await expect(app.row(image.name)).toBeHidden();
await page.goto("/category"); await page.goto("/category");
+2 -2
View File
@@ -70,7 +70,7 @@ export class App {
// Locators // Locators
row(name: string) { row(name: string) {
return this.page.locator("#container", { hasText: name }).first(); return this.page.locator("[data-entry-container]", { hasText: name }).first();
} }
// Inputs are labeled by sibling elements instead of placeholders // Inputs are labeled by sibling elements instead of placeholders
@@ -112,7 +112,7 @@ export class App {
} }
async openMenu(name: string) { async openMenu(name: string) {
await this.row(name).locator("#action-button").click(); await this.row(name).locator("[data-action-button]").click();
} }
async upload(assets: Asset[]) { async upload(assets: Asset[]) {
+56
View File
@@ -0,0 +1,56 @@
import axios from "axios";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { uploadBlob } from "#lib/client/transfer/chunked-upload.ts";
import { generateDataKey } from "#lib/shared/crypto/index.ts";
import { CHUNK_SIZE } from "#lib/shared/format/index.ts";
vi.mock("axios", () => ({ default: { post: vi.fn() } }));
const post = vi.mocked(axios.post);
const createBlob = (chunks: number) => new Blob([new Uint8Array((chunks - 1) * CHUNK_SIZE + 1)]);
beforeEach(() => {
post.mockReset();
post.mockResolvedValue({});
});
describe("uploadBlob", () => {
it("uploads every chunk", async () => {
const { dataKey } = await generateDataKey();
const { encContentHash } = await uploadBlob("upload", createBlob(3), dataKey);
expect(encContentHash).toBeTruthy();
expect(post.mock.calls.map(([url]) => url).sort()).toEqual([
"/api/upload/upload/chunks/1",
"/api/upload/upload/chunks/2",
"/api/upload/upload/chunks/3",
]);
});
it("rejects if encryption fails", async () => {
const { dataKey } = await generateDataKey();
const error = new Error("Encryption failed");
await expect(
uploadBlob("upload", createBlob(2), dataKey, {
onPlaintextChunk: async (chunkIndex) => {
if (chunkIndex === 1) throw error;
},
}),
).rejects.toBe(error);
});
it("rejects if uploading a chunk fails", async () => {
const { dataKey } = await generateDataKey();
const error = new Error("Upload failed");
post.mockImplementation(async (url) => {
if (url.endsWith("/2")) throw error;
return {};
});
// More chunks than the queue can hold, so that the producer would block if not stopped
await expect(uploadBlob("upload", createBlob(16), dataKey)).rejects.toBe(error);
});
});