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

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] });
};