diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c859d0a..0d3e02f 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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(); diff --git a/src/lib/client/crypto/hmac.ts b/src/lib/client/crypto/hmac.ts index 00c93b4..f52ebc6 100644 --- a/src/lib/client/crypto/hmac.ts +++ b/src/lib/client/crypto/hmac.ts @@ -9,7 +9,11 @@ export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => { return new Promise((resolve, reject) => { worker.onmessage = ({ data }: MessageEvent) => { - resolve(data.result); + if ("result" in data) { + resolve(data.result); + } else { + reject(data.error); + } worker.terminate(); }; diff --git a/src/lib/client/media/exif.ts b/src/lib/client/media/exif.ts index 86f7f6a..0d89ab4 100644 --- a/src/lib/client/media/exif.ts +++ b/src/lib/client/media/exif.ts @@ -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 diff --git a/src/lib/client/transfer/chunked-upload.ts b/src/lib/client/transfer/chunked-upload.ts index be08e46..cce85c8 100644 --- a/src/lib/client/transfer/chunked-upload.ts +++ b/src/lib/client/transfer/chunked-upload.ts @@ -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(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>(); + 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 = 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()) }; diff --git a/src/lib/server/crypto.ts b/src/lib/server/crypto.ts index 6bd7898..3687af6 100644 --- a/src/lib/server/crypto.ts +++ b/src/lib/server/crypto.ts @@ -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; } }; diff --git a/src/lib/ui/buttons/ActionEntryButton.svelte b/src/lib/ui/buttons/ActionEntryButton.svelte index 1d3d7a2..5807a15 100644 --- a/src/lib/ui/buttons/ActionEntryButton.svelte +++ b/src/lib/ui/buttons/ActionEntryButton.svelte @@ -24,17 +24,17 @@
setTimeout(onclick, 100))} class={["rounded-xl", className]} > -
+
{@render children()}
{#if ActionButtonIcon}