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
+127
-43
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 () => {
|
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();
|
||||||
|
|||||||
@@ -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>) => {
|
||||||
|
if ("result" in data) {
|
||||||
resolve(data.result);
|
resolve(data.result);
|
||||||
|
} else {
|
||||||
|
reject(data.error);
|
||||||
|
}
|
||||||
worker.terminate();
|
worker.terminate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,25 +87,25 @@ 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 {
|
||||||
|
while (!failure) {
|
||||||
const item = await queue.pop();
|
const item = await queue.pop();
|
||||||
if (item === null) break;
|
if (item === null) break;
|
||||||
if (encryptionError) throw encryptionError;
|
|
||||||
|
|
||||||
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);
|
activeTasks.add(task);
|
||||||
task.finally(() => activeTasks.delete(task));
|
|
||||||
|
|
||||||
if (activeTasks.size >= networkConcurrency) {
|
if (activeTasks.size >= networkConcurrency) {
|
||||||
await Promise.race(activeTasks);
|
await Promise.race(activeTasks);
|
||||||
@@ -117,10 +113,17 @@ export const uploadBlob = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()) };
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -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>
|
||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
const uploadRouter = router({
|
const uploadRouter = router({
|
||||||
startFileUpload: roleProcedure["activeClient"]
|
startFileUpload: roleProcedure["activeClient"]
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z
|
||||||
|
.object({
|
||||||
chunks: IdSchema,
|
chunks: IdSchema,
|
||||||
parent: DirectoryIdSchema,
|
parent: DirectoryIdSchema,
|
||||||
mekVersion: IdSchema,
|
mekVersion: IdSchema,
|
||||||
@@ -30,6 +31,9 @@ const uploadRouter = router({
|
|||||||
createdAtIv: Base64Schema.optional(),
|
createdAtIv: Base64Schema.optional(),
|
||||||
lastModifiedAt: Base64Schema,
|
lastModifiedAt: Base64Schema,
|
||||||
lastModifiedAtIv: Base64Schema,
|
lastModifiedAtIv: Base64Schema,
|
||||||
|
})
|
||||||
|
.refine(({ createdAt, createdAtIv }) => !createdAt === !createdAtIv, {
|
||||||
|
message: "createdAt and createdAtIv must be provided together",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
+8
-3
@@ -6,11 +6,11 @@ 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>) => {
|
||||||
|
try {
|
||||||
const h = hmac.create(sha256, event.data.key);
|
const h = hmac.create(sha256, event.data.key);
|
||||||
const reader = event.data.stream.getReader();
|
const reader = event.data.stream.getReader();
|
||||||
|
|
||||||
@@ -22,4 +22,9 @@ self.onmessage = async (event: MessageEvent<ComputeMessage>) => {
|
|||||||
|
|
||||||
const result = h.digest();
|
const result = h.digest();
|
||||||
self.postMessage({ result } satisfies ResultMessage, { transfer: [result.buffer] });
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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[]) {
|
||||||
|
|||||||
@@ -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