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

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 () => {
await migrateDB();
schedule.scheduleJob("0 * * * *", () => {
ClientRepo.cleanupExpiredUserClientChallenges();
SessionRepo.cleanupExpiredSessions();
SessionRepo.cleanupExpiredSessionUpgradeChallenges();
UploadService.cleanupExpiredUploadSessions();
schedule.scheduleJob("0 * * * *", async () => {
// Failures are logged and retried at the next run, so they don't crash the server
const results = await Promise.allSettled([
ClientRepo.cleanupExpiredUserClientChallenges(),
SessionRepo.cleanupExpiredSessions(),
SessionRepo.cleanupExpiredSessionUpgradeChallenges(),
UploadService.cleanupExpiredUploadSessions(),
]);
results.forEach((result) => {
if (result.status === "rejected") console.error(result.reason);
});
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) => {
worker.onmessage = ({ data }: MessageEvent<ResultMessage>) => {
resolve(data.result);
if ("result" in data) {
resolve(data.result);
} else {
reject(data.error);
}
worker.terminate();
};
+6 -1
View File
@@ -1,5 +1,7 @@
import ExifReader from "exifreader";
const isInteger = (value: number | undefined): value is number => Number.isInteger(value);
export const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
const exif = ExifReader.load(fileBuffer);
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
@@ -11,7 +13,10 @@ export const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
const [year, month, day] = date.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) {
// No timezone information.. Assume local timezone
+28 -25
View File
@@ -1,6 +1,5 @@
import { sha256 } from "@noble/hashes/sha2.js";
import axios from "axios";
import pLimit from "p-limit";
import { encodeToBase64, encryptChunk } from "#lib/shared/crypto/index.ts";
import { CHUNK_SIZE, getChunkCount, getEncryptedSize } from "#lib/shared/format/index.ts";
import { BoundedQueue } from "#lib/utils/concurrency/index.ts";
@@ -70,7 +69,6 @@ export const uploadBlob = async (
};
const queue = new BoundedQueue<EncryptedChunk>(maxQueueSize);
let encryptionError: Error | null = null;
// Producer: encrypt chunks and push to queue
const encryptionProducer = async () => {
@@ -82,8 +80,6 @@ export const uploadBlob = async (
hash.update(new Uint8Array(data));
await queue.push({ index: i, data });
}
} catch (e) {
encryptionError = e instanceof Error ? e : new Error(String(e));
} finally {
queue.close();
}
@@ -91,36 +87,43 @@ export const uploadBlob = async (
// Consumer: upload chunks from queue with concurrency limit
const uploadConsumer = async () => {
const limit = pLimit(networkConcurrency);
const activeTasks = new Set<Promise<void>>();
let failure: { reason: unknown } | undefined;
while (true) {
const item = await queue.pop();
if (item === null) break;
if (encryptionError) throw encryptionError;
try {
while (!failure) {
const item = await queue.pop();
if (item === null) break;
const task = limit(async () => {
try {
await uploadEncryptedChunk(uploadId, item.index, item.data, onChunkProgress);
} finally {
// @ts-expect-error
item.data = null;
const task: Promise<void> = uploadEncryptedChunk(
uploadId,
item.index,
item.data,
onChunkProgress,
)
.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
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() });
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) => {
const [sessionIdHex, sessionIdHmac] = sessionIdSigned.split(".");
if (!sessionIdHex || !sessionIdHmac) return;
if (
timingSafeEqual(
Buffer.from(sessionIdHmac, "hex"),
createHmac("sha256", secret).update(Buffer.from(sessionIdHex, "hex")).digest(),
)
) {
const actualHmac = Buffer.from(sessionIdHmac, "hex");
const expectedHmac = createHmac("sha256", secret)
.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;
}
};
+5 -5
View File
@@ -24,17 +24,17 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
id="container"
data-entry-container
onclick={onclick && (() => setTimeout(onclick, 100))}
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">
{@render children()}
</div>
{#if ActionButtonIcon}
<button
id="action-button"
data-action-button
onclick={(e) => {
e.stopPropagation();
if (onActionButtonClick) {
@@ -52,10 +52,10 @@
<style lang="postcss">
@reference "../../../app.css";
#container:active:not(:has(#action-button:active)) {
[data-entry-container]:active:not(:has([data-action-button]:active)) {
@apply bg-gray-100;
}
#children:active:not(:has(#action-button:active)) {
[data-entry-content]:active:not(:has([data-action-button]:active)) {
@apply scale-95;
}
</style>
+22 -18
View File
@@ -13,24 +13,28 @@ import {
const uploadRouter = router({
startFileUpload: roleProcedure["activeClient"]
.input(
z.object({
chunks: IdSchema,
parent: DirectoryIdSchema,
mekVersion: IdSchema,
dek: Base64Schema,
dekVersion: z.date(),
hskVersion: IdSchema.optional(),
contentType: z
.string()
.trim()
.nonempty()
.refine((value) => mime.getExtension(value) !== null),
...EncryptedNameSchema,
createdAt: Base64Schema.optional(),
createdAtIv: Base64Schema.optional(),
lastModifiedAt: Base64Schema,
lastModifiedAtIv: Base64Schema,
}),
z
.object({
chunks: IdSchema,
parent: DirectoryIdSchema,
mekVersion: IdSchema,
dek: Base64Schema,
dekVersion: z.date(),
hskVersion: IdSchema.optional(),
contentType: z
.string()
.trim()
.nonempty()
.refine((value) => mime.getExtension(value) !== null),
...EncryptedNameSchema,
createdAt: Base64Schema.optional(),
createdAtIv: Base64Schema.optional(),
lastModifiedAt: Base64Schema,
lastModifiedAtIv: Base64Schema,
})
.refine(({ createdAt, createdAtIv }) => !createdAt === !createdAtIv, {
message: "createdAt and createdAtIv must be provided together",
}),
)
.mutation(async ({ ctx, input }) => {
return await UploadService.startFileUpload({
+17 -12
View File
@@ -6,20 +6,25 @@ export interface ComputeMessage {
key: Uint8Array;
}
export interface ResultMessage {
result: Uint8Array;
}
// Errors are sent as messages, as rejections in `onmessage` don't fire `error` on the worker
export type ResultMessage = { result: Uint8Array } | { error: Error };
self.onmessage = async (event: MessageEvent<ComputeMessage>) => {
const h = hmac.create(sha256, event.data.key);
const reader = event.data.stream.getReader();
try {
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);
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] });
} 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 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 app.page.goto("/directory");
@@ -105,7 +105,7 @@ test("카테고리를 만들고 파일을 추가, 제거한 뒤 이름을 바꾸
await app.row("여행").click();
await page.waitForURL(/\/category\/\d+/);
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 page.goto("/category");
+2 -2
View File
@@ -70,7 +70,7 @@ export class App {
// Locators
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
@@ -112,7 +112,7 @@ export class App {
}
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[]) {
+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);
});
});