mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
형식이 잘못된 세션 쿠키로 인해 서버 오류가 발생하던 문제와 업로드 중 오류가 무시되거나 무한히 대기하던 문제 등 버그 수정
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bb0328b39e
commit
9119c21881
11 files changed
+161
-77
No files matched your search
+11
-5
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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] });
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user