From 234f3998ad3fda62ea3b9c8df947c804e5a0cbae Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:50:32 -0600 Subject: [PATCH] Restore the community photo after reload and reject empty, oversized, SVG, and spoofed uploads. Co-authored-by: Cursor --- .../CreateFlowPendingAvatarFlush.tsx | 20 +- ...stomMethodCardUploadBlockRow.container.tsx | 9 +- .../CustomMethodCardUploadBlockRow.view.tsx | 3 +- .../CustomMethodCardWizard.container.tsx | 6 +- ...CustomMethodCardWizardFieldBodies.view.tsx | 2 + .../screens/upload/CommunityUploadScreen.tsx | 94 +++- app/api/uploads/route.ts | 59 ++- lib/create/createFlowUploadValidation.ts | 402 ++++++++++++++++++ lib/create/pendingCommunityAvatarUpload.ts | 17 +- lib/create/uploadToServer.ts | 66 ++- lib/server/uploads/saveCreateFlowUpload.ts | 34 +- lib/server/uploads/uploadConstants.ts | 33 +- messages/en/create/upload.json | 8 +- tests/components/UploadPage.test.tsx | 138 +++++- tests/unit/createFlowUploadConstants.test.ts | 13 - tests/unit/createFlowUploadValidation.test.ts | 164 +++++++ tests/unit/saveCreateFlowUpload.test.ts | 57 +++ tests/unit/uploadsPostRoute.test.ts | 59 ++- 18 files changed, 1072 insertions(+), 112 deletions(-) create mode 100644 lib/create/createFlowUploadValidation.ts create mode 100644 tests/unit/createFlowUploadValidation.test.ts create mode 100644 tests/unit/saveCreateFlowUpload.test.ts diff --git a/app/(app)/create/components/CreateFlowPendingAvatarFlush.tsx b/app/(app)/create/components/CreateFlowPendingAvatarFlush.tsx index 4a2c983..d8eb262 100644 --- a/app/(app)/create/components/CreateFlowPendingAvatarFlush.tsx +++ b/app/(app)/create/components/CreateFlowPendingAvatarFlush.tsx @@ -2,7 +2,10 @@ import { useEffect, useRef } from "react"; import { useCreateFlow } from "../context/CreateFlowContext"; -import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer"; +import { + CreateFlowUploadValidationError, + uploadCreateFlowFile, +} from "../../../../lib/create/uploadToServer"; import { clearPendingCommunityAvatarFile, readPendingCommunityAvatarFile, @@ -19,13 +22,17 @@ export function CreateFlowPendingAvatarFlush({ sessionUser: { id: string; email: string } | null | undefined; sessionResolved: boolean; }) { - const { updateState } = useCreateFlow(); + const { state, updateState } = useCreateFlow(); /** One successful flush per signed-in user id (survives React StrictMode remounts). */ const lastFlushedUserIdRef = useRef(null); + const hasServerAvatar = + typeof state.communityAvatarUrl === "string" && + state.communityAvatarUrl.trim().length > 0; useEffect(() => { if (!sessionResolved || !sessionUser) return; if (lastFlushedUserIdRef.current === sessionUser.id) return; + if (hasServerAvatar) return; let cancelled = false; void (async () => { @@ -37,15 +44,18 @@ export function CreateFlowPendingAvatarFlush({ await clearPendingCommunityAvatarFile(); updateState({ communityAvatarUrl: url }); lastFlushedUserIdRef.current = sessionUser.id; - } catch { - // Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT. + } catch (err) { + if (err instanceof CreateFlowUploadValidationError) { + await clearPendingCommunityAvatarFile(); + } + // Leave a transient (auth / UPLOAD_ROOT) failure in place to retry. } })(); return () => { cancelled = true; }; - }, [sessionResolved, sessionUser, updateState]); + }, [hasServerAvatar, sessionResolved, sessionUser, updateState]); return null; } diff --git a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.container.tsx b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.container.tsx index 3f18af1..3b6b44b 100644 --- a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.container.tsx +++ b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.container.tsx @@ -3,7 +3,10 @@ import { memo, useCallback, useRef, useState } from "react"; import { useTranslation } from "../../../../contexts/MessagesContext"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; -import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; +import { + createFlowUploadFailureMessageKey, + uploadCreateFlowFile, +} from "../../../../../lib/create/uploadToServer"; import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view"; import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types"; @@ -68,8 +71,8 @@ function CustomMethodCardUploadBlockRowContainerComponent({ : b, ), ); - } catch { - setErrorMessage(tUpload("errors.generic")); + } catch (err) { + setErrorMessage(tUpload(createFlowUploadFailureMessageKey(err))); } finally { setBusy(false); } diff --git a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.view.tsx b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.view.tsx index 4d7faa3..87f7d46 100644 --- a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.view.tsx +++ b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.view.tsx @@ -4,6 +4,7 @@ import { memo } from "react"; import Upload from "../../../../components/controls/Upload"; import InputLabel from "../../../../components/type/InputLabel"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; +import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation"; import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types"; function CustomMethodCardUploadBlockRowViewComponent({ @@ -43,7 +44,7 @@ function CustomMethodCardUploadBlockRowViewComponent({ type="file" className="sr-only" tabIndex={-1} - accept="image/jpeg,image/png,image/webp,image/gif,application/pdf" + accept={CUSTOM_ATTACHMENT_ACCEPT} aria-label={uploadFileInputAriaLabel} onChange={onFileInputChange} /> diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx index f412c9d..11602aa 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx @@ -8,6 +8,7 @@ import { import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; +import { createFlowUploadFailureMessageKey } from "../../../../../lib/create/uploadToServer"; import { CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS, CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS, @@ -462,8 +463,9 @@ const CustomMethodCardWizardContainer = memo( try { const { url } = await onPersistCustomUploadFile(file); setUploadAssetUrl(url); - } catch { - setUploadFieldError(tUpload("errors.generic")); + } catch (err) { + setUploadFileName(undefined); + setUploadFieldError(tUpload(createFlowUploadFailureMessageKey(err))); } finally { setUploadFieldBusy(false); } diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardFieldBodies.view.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardFieldBodies.view.tsx index 0148a61..eeaa7d5 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardFieldBodies.view.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardFieldBodies.view.tsx @@ -10,6 +10,7 @@ import IncrementerBlock from "../../../../components/controls/IncrementerBlock"; import InputLabel from "../../../../components/type/InputLabel"; import ApplicableScopeField from "../ApplicableScopeField"; import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants"; +import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation"; import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types"; const TEXT_PLACEHOLDER_MAX = 8000; @@ -119,6 +120,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({ type="file" className="sr-only" tabIndex={-1} + accept={CUSTOM_ATTACHMENT_ACCEPT} aria-label={copy.upload.uploadFileInputAriaLabel} onChange={onFileChosen} /> diff --git a/app/(app)/create/screens/upload/CommunityUploadScreen.tsx b/app/(app)/create/screens/upload/CommunityUploadScreen.tsx index ee47df6..5aa8af1 100644 --- a/app/(app)/create/screens/upload/CommunityUploadScreen.tsx +++ b/app/(app)/create/screens/upload/CommunityUploadScreen.tsx @@ -16,14 +16,24 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL import { fetchAuthSession } from "../../../../../lib/create/api"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; import { - UploadToServerError, + createFlowUploadFailureMessageKey, uploadCreateFlowFile, } from "../../../../../lib/create/uploadToServer"; +import { + COMMUNITY_AVATAR_ACCEPT, + messageKeyForCreateFlowUploadReason, + validateCreateFlowUploadFile, +} from "../../../../../lib/create/createFlowUploadValidation"; import { clearPendingCommunityAvatarFile, + readPendingCommunityAvatarFile, storePendingCommunityAvatarFile, } from "../../../../../lib/create/pendingCommunityAvatarUpload"; +function hasCommunityAvatarUrl(url: string | undefined): boolean { + return typeof url === "string" && url.trim().length > 0; +} + /** Create Community — Figma Flow — Upload `20094:41524`. */ export function CommunityUploadScreen() { const m = useMessages(); @@ -54,17 +64,57 @@ export function CommunityUploadScreen() { [localPreviewUrl], ); - const resolveUploadError = useCallback( - (err: unknown) => { - if (err instanceof UploadToServerError) { - if (err.status === 413) return tUpload("errors.tooLarge"); - if (err.status === 401) return tUpload("errors.unauthorized"); - if (err.code === "server_misconfigured") { - return tUpload("errors.misconfigured"); + const serverAvatarUrl = hasCommunityAvatarUrl(state.communityAvatarUrl) + ? state.communityAvatarUrl!.trim() + : null; + const serverAvatarUrlRef = useRef(serverAvatarUrl); + serverAvatarUrlRef.current = serverAvatarUrl; + + useEffect(() => { + if (serverAvatarUrl) { + setLocalPreviewUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + } + }, [serverAvatarUrl]); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const file = await readPendingCommunityAvatarFile(); + if (cancelled || !file) return; + const validated = await validateCreateFlowUploadFile( + file, + "communityAvatar", + ); + if (validated.ok === false) { + await clearPendingCommunityAvatarFile(); + return; } + if (cancelled) return; + if (serverAvatarUrlRef.current) return; + const objectUrl = URL.createObjectURL(file); + if (cancelled) { + URL.revokeObjectURL(objectUrl); + return; + } + setLocalPreviewUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return objectUrl; + }); + } catch { + // Missing IndexedDB / quota: leave the picker empty. } - return tUpload("errors.generic"); - }, + })(); + return () => { + cancelled = true; + }; + }, []); + + const resolveUploadError = useCallback( + (err: unknown) => tUpload(createFlowUploadFailureMessageKey(err)), [tUpload], ); @@ -94,6 +144,16 @@ export function CommunityUploadScreen() { } if (signedIn === false) { + const validated = await validateCreateFlowUploadFile( + file, + "communityAvatar", + ); + if (validated.ok === false) { + setErrorMessage( + tUpload(messageKeyForCreateFlowUploadReason(validated.reason)), + ); + return; + } try { await storePendingCommunityAvatarFile(file); setLocalPreviewUrl((prev) => { @@ -121,24 +181,16 @@ export function CommunityUploadScreen() { if (prev) URL.revokeObjectURL(prev); return null; }); - if ( - typeof state.communityAvatarUrl === "string" && - state.communityAvatarUrl.trim().length > 0 - ) { + if (hasCommunityAvatarUrl(state.communityAvatarUrl)) { updateState({ communityAvatarUrl: undefined }); } - // Clear any anonymous staged blob so the post-sign-in flush won't resurrect it. void clearPendingCommunityAvatarFile(); if (fileInputRef.current) { fileInputRef.current.value = ""; } }, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]); - const displaySrc = - typeof state.communityAvatarUrl === "string" && - state.communityAvatarUrl.trim().length > 0 - ? state.communityAvatarUrl.trim() - : localPreviewUrl; + const displaySrc = serverAvatarUrl ?? localPreviewUrl; const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0; return ( @@ -161,7 +213,7 @@ export function CommunityUploadScreen() { type="file" className="sr-only" tabIndex={-1} - accept="image/jpeg,image/png,image/webp,image/gif" + accept={COMMUNITY_AVATAR_ACCEPT} aria-label={u.hintText} onChange={handleFileChange} /> diff --git a/app/api/uploads/route.ts b/app/api/uploads/route.ts index f16d086..286187f 100644 --- a/app/api/uploads/route.ts +++ b/app/api/uploads/route.ts @@ -14,13 +14,40 @@ import { saveCreateFlowUpload } from "../../../lib/server/uploads/saveCreateFlow import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot"; import { CREATE_FLOW_UPLOAD_MAX_BYTES, + maxBytesForPurpose, type CreateFlowUploadPurpose, } from "../../../lib/server/uploads/uploadConstants"; +import type { CreateFlowUploadValidationReason } from "../../../lib/create/createFlowUploadValidation"; + +function asUploadedBlob(value: FormDataEntryValue | null): Blob | null { + if (typeof value !== "object" || value === null) return null; + const candidate = value as Partial; + if (typeof candidate.arrayBuffer !== "function") return null; + if (typeof candidate.size !== "number") return null; + return value as Blob; +} function isPurpose(x: string): x is CreateFlowUploadPurpose { return x === "communityAvatar" || x === "customMethodAttachment"; } +function messageForValidationReason( + reason: CreateFlowUploadValidationReason, +): string { + switch (reason) { + case "empty": + return "File is empty."; + case "tooLarge": + return "File exceeds the maximum allowed size for this upload purpose."; + case "svg": + return "SVG uploads are not allowed."; + case "undecodable": + return "File could not be decoded as a valid image."; + case "invalidType": + return "File type is not allowed for this upload purpose."; + } +} + export const POST = apiRoute("uploads.post", async (request: NextRequest) => { if (!isDatabaseConfigured()) { return dbUnavailable(); @@ -54,7 +81,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => { } const purposeRaw = formData.get("purpose"); - const file = formData.get("file"); + const file = asUploadedBlob(formData.get("file")); if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) { return errorJson( @@ -64,7 +91,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => { ); } - if (!(file instanceof File)) { + if (!file) { return errorJson( "validation_error", "Missing `file` field (multipart file).", @@ -72,21 +99,29 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => { ); } - if (file.size > CREATE_FLOW_UPLOAD_MAX_BYTES) { + if (file.size === 0) { + return errorJson("validation_error", messageForValidationReason("empty"), 400, { + details: { reason: "empty" }, + }); + } + + if ( + file.size > CREATE_FLOW_UPLOAD_MAX_BYTES || + file.size > maxBytesForPurpose(purposeRaw) + ) { return errorJson( "payload_too_large", - `File exceeds maximum allowed size (${CREATE_FLOW_UPLOAD_MAX_BYTES} bytes).`, + messageForValidationReason("tooLarge"), 413, + { details: { reason: "tooLarge" } }, ); } const buf = Buffer.from(await file.arrayBuffer()); - const mimeType = file.type || "application/octet-stream"; const saved = await saveCreateFlowUpload({ purpose: purposeRaw, buffer: buf, - mimeType, }); if ("error" in saved) { @@ -95,10 +130,20 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => { "File uploads are not configured (UPLOAD_ROOT is unset).", ); } + const reason = saved.reason ?? "invalidType"; + if (reason === "tooLarge") { + return errorJson( + "payload_too_large", + messageForValidationReason("tooLarge"), + 413, + { details: { reason } }, + ); + } return errorJson( "validation_error", - "File type or size is not allowed for this upload purpose.", + messageForValidationReason(reason), 400, + { details: { reason } }, ); } diff --git a/lib/create/createFlowUploadValidation.ts b/lib/create/createFlowUploadValidation.ts new file mode 100644 index 0000000..25f0650 --- /dev/null +++ b/lib/create/createFlowUploadValidation.ts @@ -0,0 +1,402 @@ +import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose"; + +/** Community avatar cap (bytes). */ +const COMMUNITY_AVATAR_MAX_BYTES = 5 * 1024 * 1024; +/** Custom-method attachment cap (bytes). */ +const CUSTOM_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024; + +export const COMMUNITY_AVATAR_ACCEPT = + "image/jpeg,image/png,image/webp,image/gif"; +export const CUSTOM_ATTACHMENT_ACCEPT = `${COMMUNITY_AVATAR_ACCEPT},application/pdf`; + +export type CreateFlowUploadValidationReason = + | "empty" + | "tooLarge" + | "svg" + | "invalidType" + | "undecodable"; + +type SniffedUploadKind = + | "jpeg" + | "png" + | "gif" + | "webp" + | "pdf" + | "svg" + | "empty" + | "unknown"; + +type CreateFlowUploadRasterKind = "jpeg" | "png" | "gif" | "webp"; + +type CreateFlowUploadValidationOk = { + ok: true; + kind: CreateFlowUploadRasterKind | "pdf"; + mimeType: string; +}; + +type CreateFlowUploadValidationFail = { + ok: false; + reason: CreateFlowUploadValidationReason; +}; + +export type CreateFlowUploadValidationResult = + | CreateFlowUploadValidationOk + | CreateFlowUploadValidationFail; + +export class CreateFlowUploadValidationError extends Error { + readonly reason: CreateFlowUploadValidationReason; + + constructor(reason: CreateFlowUploadValidationReason) { + super(reason); + this.name = "CreateFlowUploadValidationError"; + this.reason = reason; + } +} + +const SVG_MIME = new Set(["image/svg+xml", "image/svg"]); + +export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number { + return purpose === "communityAvatar" + ? COMMUNITY_AVATAR_MAX_BYTES + : CUSTOM_ATTACHMENT_MAX_BYTES; +} + +function mimeTypeForSniffedKind( + kind: CreateFlowUploadRasterKind | "pdf", +): string { + switch (kind) { + case "jpeg": + return "image/jpeg"; + case "png": + return "image/png"; + case "gif": + return "image/gif"; + case "webp": + return "image/webp"; + case "pdf": + return "application/pdf"; + } +} + +export function messageKeyForCreateFlowUploadReason( + reason: CreateFlowUploadValidationReason, +): `errors.${CreateFlowUploadValidationReason}` { + return `errors.${reason}`; +} + +function fileLooksLikeSvg(file: File): boolean { + const declared = file.type.toLowerCase().split(";")[0]?.trim() ?? ""; + if (SVG_MIME.has(declared)) return true; + return /\.svgz?$/i.test(file.name); +} + +function readU16LE(bytes: Uint8Array, offset: number): number { + return bytes[offset]! | (bytes[offset + 1]! << 8); +} + +function readU16BE(bytes: Uint8Array, offset: number): number { + return (bytes[offset]! << 8) | bytes[offset + 1]!; +} + +function readU24LE(bytes: Uint8Array, offset: number): number { + return ( + bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) + ); +} + +function readU32BE(bytes: Uint8Array, offset: number): number { + return ( + ((bytes[offset]! << 24) | + (bytes[offset + 1]! << 16) | + (bytes[offset + 2]! << 8) | + bytes[offset + 3]!) >>> + 0 + ); +} + +function startsWith(bytes: Uint8Array, offset: number, ascii: string): boolean { + if (offset + ascii.length > bytes.length) return false; + for (let i = 0; i < ascii.length; i++) { + if (bytes[offset + i] !== ascii.charCodeAt(i)) return false; + } + return true; +} + +function looksLikeSvgBytes(bytes: Uint8Array): boolean { + let i = 0; + if ( + bytes.length >= 3 && + bytes[0] === 0xef && + bytes[1] === 0xbb && + bytes[2] === 0xbf + ) { + i = 3; + } + while ( + i < bytes.length && + (bytes[i] === 0x20 || + bytes[i] === 0x09 || + bytes[i] === 0x0d || + bytes[i] === 0x0a) + ) { + i += 1; + } + const head = new TextDecoder("utf-8", { fatal: false }) + .decode(bytes.subarray(i, Math.min(i + 512, bytes.length))) + .toLowerCase(); + return head.includes("= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return "jpeg"; + } + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return "png"; + } + if (startsWith(bytes, 0, "GIF87a") || startsWith(bytes, 0, "GIF89a")) { + return "gif"; + } + if ( + bytes.length >= 12 && + startsWith(bytes, 0, "RIFF") && + startsWith(bytes, 8, "WEBP") + ) { + return "webp"; + } + if (bytes.length >= 5 && startsWith(bytes, 0, "%PDF-")) { + return "pdf"; + } + if (looksLikeSvgBytes(bytes)) return "svg"; + return "unknown"; +} + +function pngDimensions( + bytes: Uint8Array, +): { width: number; height: number } | null { + if (bytes.length < 24 || !startsWith(bytes, 12, "IHDR")) return null; + const width = readU32BE(bytes, 16); + const height = readU32BE(bytes, 20); + if (width < 1 || height < 1) return null; + return { width, height }; +} + +function gifDimensions( + bytes: Uint8Array, +): { width: number; height: number } | null { + if (bytes.length < 10) return null; + const width = readU16LE(bytes, 6); + const height = readU16LE(bytes, 8); + if (width < 1 || height < 1) return null; + return { width, height }; +} + +function jpegDimensions( + bytes: Uint8Array, +): { width: number; height: number } | null { + if (bytes.length < 4) return null; + let offset = 2; + while (offset + 8 < bytes.length) { + if (bytes[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = bytes[offset + 1]!; + if (marker === 0xff) { + offset += 1; + continue; + } + if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { + offset += 2; + continue; + } + if (offset + 3 >= bytes.length) return null; + const size = readU16BE(bytes, offset + 2); + if (size < 2) return null; + const isSof = + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf); + if (isSof) { + if (offset + 8 >= bytes.length) return null; + const height = readU16BE(bytes, offset + 5); + const width = readU16BE(bytes, offset + 7); + if (width < 1 || height < 1) return null; + return { width, height }; + } + offset += 2 + size; + } + return null; +} + +function webpDimensions( + bytes: Uint8Array, +): { width: number; height: number } | null { + if (bytes.length < 20) return null; + if (startsWith(bytes, 12, "VP8X")) { + if (bytes.length < 30) return null; + const width = readU24LE(bytes, 24) + 1; + const height = readU24LE(bytes, 27) + 1; + if (width < 1 || height < 1) return null; + return { width, height }; + } + if (startsWith(bytes, 12, "VP8L")) { + if (bytes.length < 25 || bytes[20] !== 0x2f) return null; + const bits = + bytes[21]! | + (bytes[22]! << 8) | + (bytes[23]! << 16) | + (bytes[24]! << 24); + const width = (bits & 0x3fff) + 1; + const height = ((bits >> 14) & 0x3fff) + 1; + if (width < 1 || height < 1) return null; + return { width, height }; + } + if (startsWith(bytes, 12, "VP8 ")) { + if (bytes.length < 30) return null; + if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) { + return null; + } + const width = readU16LE(bytes, 26) & 0x3fff; + const height = readU16LE(bytes, 28) & 0x3fff; + if (width < 1 || height < 1) return null; + return { width, height }; + } + return null; +} + +function rasterDimensions( + kind: CreateFlowUploadRasterKind, + bytes: Uint8Array, +): { width: number; height: number } | null { + switch (kind) { + case "png": + return pngDimensions(bytes); + case "gif": + return gifDimensions(bytes); + case "jpeg": + return jpegDimensions(bytes); + case "webp": + return webpDimensions(bytes); + } +} + +function purposeAllowsKind( + purpose: CreateFlowUploadPurpose, + kind: CreateFlowUploadRasterKind | "pdf", +): boolean { + if (kind === "pdf") return purpose === "customMethodAttachment"; + return true; +} + +/** + * Size, true-type, SVG, and header-decode checks shared by the browser and + * `POST /api/uploads`. Call {@link validateCreateFlowUploadFile} on the client + * so oversized files are rejected before they are read into memory. + */ +export function validateCreateFlowUploadBytes( + purpose: CreateFlowUploadPurpose, + bytes: Uint8Array, +): CreateFlowUploadValidationResult { + if (bytes.length === 0) return { ok: false, reason: "empty" }; + if (bytes.length > maxBytesForPurpose(purpose)) { + return { ok: false, reason: "tooLarge" }; + } + + const sniffed = sniffCreateFlowUploadBytes(bytes); + if (sniffed === "empty") return { ok: false, reason: "empty" }; + if (sniffed === "svg") return { ok: false, reason: "svg" }; + if (sniffed === "unknown") return { ok: false, reason: "invalidType" }; + if (!purposeAllowsKind(purpose, sniffed)) { + return { ok: false, reason: "invalidType" }; + } + if (sniffed === "pdf") { + return { ok: true, kind: "pdf", mimeType: mimeTypeForSniffedKind("pdf") }; + } + + if (rasterDimensions(sniffed, bytes) == null) { + return { ok: false, reason: "undecodable" }; + } + + return { + ok: true, + kind: sniffed, + mimeType: mimeTypeForSniffedKind(sniffed), + }; +} + +async function readBlobBytes(blob: Blob): Promise { + if (typeof blob.arrayBuffer === "function") { + return new Uint8Array(await blob.arrayBuffer()); + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(new Uint8Array(reader.result as ArrayBuffer)); + }; + reader.onerror = () => { + reject(reader.error ?? new Error("FileReader failed")); + }; + reader.readAsArrayBuffer(blob); + }); +} + +async function decodeRasterInBrowser( + bytes: Uint8Array, + mimeType: string, +): Promise { + if (typeof createImageBitmap !== "function") return true; + try { + const copy = new Uint8Array(bytes); + const bitmap = await createImageBitmap( + new Blob([copy], { type: mimeType }), + ); + const ok = bitmap.width > 0 && bitmap.height > 0; + bitmap.close(); + return ok; + } catch { + return false; + } +} + +/** + * Client-side validation: size and SVG name/type before reading bytes, then + * the same sniff/decode path as the server, plus `createImageBitmap` when + * available. + */ +export async function validateCreateFlowUploadFile( + file: File, + purpose: CreateFlowUploadPurpose, +): Promise { + if (file.size === 0) return { ok: false, reason: "empty" }; + if (file.size > maxBytesForPurpose(purpose)) { + return { ok: false, reason: "tooLarge" }; + } + if (fileLooksLikeSvg(file)) return { ok: false, reason: "svg" }; + + const bytes = await readBlobBytes(file); + const result = validateCreateFlowUploadBytes(purpose, bytes); + if (result.ok === false || result.kind === "pdf") return result; + + const decoded = await decodeRasterInBrowser(bytes, result.mimeType); + if (!decoded) return { ok: false, reason: "undecodable" }; + return result; +} diff --git a/lib/create/pendingCommunityAvatarUpload.ts b/lib/create/pendingCommunityAvatarUpload.ts index 16213d0..199caec 100644 --- a/lib/create/pendingCommunityAvatarUpload.ts +++ b/lib/create/pendingCommunityAvatarUpload.ts @@ -22,7 +22,20 @@ function openDb(): Promise { }); } +function coerceToFile(value: unknown): File | null { + if (value instanceof File) return value; + if (typeof Blob !== "undefined" && value instanceof Blob) { + return new File([value], "community-avatar", { + type: value.type || "application/octet-stream", + }); + } + return null; +} + export async function storePendingCommunityAvatarFile(file: File): Promise { + if (typeof indexedDB === "undefined") { + throw new Error("indexedDB is not available"); + } const db = await openDb(); try { await new Promise((resolve, reject) => { @@ -38,6 +51,7 @@ export async function storePendingCommunityAvatarFile(file: File): Promise /** Read staged file without removing it (caller clears after successful upload). */ export async function readPendingCommunityAvatarFile(): Promise { + if (typeof indexedDB === "undefined") return null; const db = await openDb(); try { return await new Promise((resolve, reject) => { @@ -45,8 +59,7 @@ export async function readPendingCommunityAvatarFile(): Promise { tx.onerror = () => reject(tx.error ?? new Error("indexedDB read failed")); const getReq = tx.objectStore(STORE).get(KEY); getReq.onsuccess = () => { - const v = getReq.result; - resolve(v instanceof File ? v : null); + resolve(coerceToFile(getReq.result)); }; getReq.onerror = () => reject(getReq.error); }); diff --git a/lib/create/uploadToServer.ts b/lib/create/uploadToServer.ts index 1d7ff8b..b5c423b 100644 --- a/lib/create/uploadToServer.ts +++ b/lib/create/uploadToServer.ts @@ -1,4 +1,10 @@ import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose"; +import { + CreateFlowUploadValidationError, + messageKeyForCreateFlowUploadReason, + validateCreateFlowUploadFile, + type CreateFlowUploadValidationReason, +} from "./createFlowUploadValidation"; export type UploadToServerResult = { url: string; @@ -7,6 +13,20 @@ export type UploadToServerResult = { byteLength: number; }; +const VALIDATION_REASONS = new Set([ + "empty", + "tooLarge", + "svg", + "invalidType", + "undecodable", +]); + +function reasonFromUnknown(value: unknown): CreateFlowUploadValidationReason | null { + return typeof value === "string" && VALIDATION_REASONS.has(value as CreateFlowUploadValidationReason) + ? (value as CreateFlowUploadValidationReason) + : null; +} + /** * Authenticated multipart upload to `POST /api/uploads`. * Caller must have a session cookie (same-origin fetch). @@ -15,6 +35,11 @@ export async function uploadCreateFlowFile( file: File, purpose: CreateFlowUploadPurpose, ): Promise { + const validated = await validateCreateFlowUploadFile(file, purpose); + if (validated.ok === false) { + throw new CreateFlowUploadValidationError(validated.reason); + } + const formData = new FormData(); formData.append("purpose", purpose); formData.append("file", file); @@ -36,14 +61,24 @@ export async function uploadCreateFlowFile( if (body && typeof body === "object" && "error" in body) { const e = (body as { error?: { message?: string; code?: string }; + details?: { reason?: unknown }; }).error; - if (!e) return { message: null as string | null, code: null as string | null }; + const details = (body as { details?: { reason?: unknown } }).details; + const reason = reasonFromUnknown(details?.reason); + if (!e) { + return { + message: null as string | null, + code: null as string | null, + reason, + }; + } return { message: typeof e.message === "string" ? e.message : null, code: typeof e.code === "string" ? e.code : null, + reason, }; } - return { message: null, code: null }; + return { message: null, code: null, reason: null }; })(); if (!res.ok) { @@ -54,7 +89,7 @@ export async function uploadCreateFlowFile( ? "UNAUTHORIZED" : "UPLOAD_FAILED"; const code = errParts.code ?? errParts.message ?? fallback; - throw new UploadToServerError(res.status, code); + throw new UploadToServerError(res.status, code, errParts.reason); } const data = body as { @@ -83,11 +118,34 @@ export async function uploadCreateFlowFile( export class UploadToServerError extends Error { readonly status: number; readonly code: string; + readonly reason: CreateFlowUploadValidationReason | null; - constructor(status: number, code: string) { + constructor( + status: number, + code: string, + reason: CreateFlowUploadValidationReason | null = null, + ) { super(code); this.name = "UploadToServerError"; this.status = status; this.code = code; + this.reason = reason; } } + +export function createFlowUploadFailureMessageKey(err: unknown): string { + if (err instanceof CreateFlowUploadValidationError) { + return messageKeyForCreateFlowUploadReason(err.reason); + } + if (err instanceof UploadToServerError) { + if (err.reason) return messageKeyForCreateFlowUploadReason(err.reason); + if (err.status === 413) return "errors.tooLarge"; + if (err.status === 401) return "errors.unauthorized"; + if (err.code === "server_misconfigured") { + return "errors.misconfigured"; + } + } + return "errors.generic"; +} + +export { CreateFlowUploadValidationError }; diff --git a/lib/server/uploads/saveCreateFlowUpload.ts b/lib/server/uploads/saveCreateFlowUpload.ts index 2ae6459..4badd73 100644 --- a/lib/server/uploads/saveCreateFlowUpload.ts +++ b/lib/server/uploads/saveCreateFlowUpload.ts @@ -2,12 +2,12 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; import { randomUUID } from "node:crypto"; import type { CreateFlowUploadPurpose } from "./uploadConstants"; -import { - extensionForMime, - isAllowedMime, - maxBytesForPurpose, -} from "./uploadConstants"; +import { extensionForMime } from "./uploadConstants"; import { ensureUploadRootExists, getUploadRootFromEnv } from "./uploadRoot"; +import { + validateCreateFlowUploadBytes, + type CreateFlowUploadValidationReason, +} from "../../create/createFlowUploadValidation"; export type SaveCreateFlowUploadResult = { /** Filename stem (UUID) without extension — used in GET URL. */ @@ -18,30 +18,32 @@ export type SaveCreateFlowUploadResult = { byteLength: number; }; +export type SaveCreateFlowUploadFailure = { + error: "misconfigured" | "validation"; + reason?: CreateFlowUploadValidationReason; +}; + /** * Writes bytes under `UPLOAD_ROOT/{id}{ext}` and returns a stable app URL path. + * Trusts sniffed bytes, not the client-declared MIME type. */ export async function saveCreateFlowUpload(params: { purpose: CreateFlowUploadPurpose; buffer: Buffer; - /** Declared MIME from the client `File.type` (validated server-side). */ - mimeType: string; -}): Promise { +}): Promise { const root = getUploadRootFromEnv(); if (!root) { return { error: "misconfigured" }; } - const { purpose, buffer, mimeType } = params; - if (buffer.length > maxBytesForPurpose(purpose)) { - return { error: "validation" }; - } - if (!isAllowedMime(purpose, mimeType)) { - return { error: "validation" }; + const { purpose, buffer } = params; + const validated = validateCreateFlowUploadBytes(purpose, buffer); + if (validated.ok === false) { + return { error: "validation", reason: validated.reason }; } const id = randomUUID(); - const ext = extensionForMime(mimeType); + const ext = extensionForMime(validated.mimeType); const fileName = `${id}${ext}`; const absolutePath = path.join(root, fileName); @@ -51,7 +53,7 @@ export async function saveCreateFlowUpload(params: { return { id, urlPath: `/api/uploads/${id}`, - mimeType: mimeType.toLowerCase().split(";")[0]?.trim() ?? "application/octet-stream", + mimeType: validated.mimeType, byteLength: buffer.length, }; } diff --git a/lib/server/uploads/uploadConstants.ts b/lib/server/uploads/uploadConstants.ts index 8864847..53e7e93 100644 --- a/lib/server/uploads/uploadConstants.ts +++ b/lib/server/uploads/uploadConstants.ts @@ -1,39 +1,10 @@ -import type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose"; - -export type { CreateFlowUploadPurpose }; +export type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose"; export { CREATE_FLOW_UPLOAD_PURPOSES } from "../../create/createFlowUploadPurpose"; +export { maxBytesForPurpose } from "../../create/createFlowUploadValidation"; /** Max body size for multipart upload (bytes). */ export const CREATE_FLOW_UPLOAD_MAX_BYTES = 12 * 1024 * 1024; -const COMMUNITY_MAX = 5 * 1024 * 1024; -const CUSTOM_MAX = 10 * 1024 * 1024; - -const IMAGE_MIMES = new Set([ - "image/jpeg", - "image/png", - "image/webp", - "image/gif", -]); - -const CUSTOM_EXTRA_MIMES = new Set(["application/pdf"]); - -export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number { - return purpose === "communityAvatar" ? COMMUNITY_MAX : CUSTOM_MAX; -} - -export function isAllowedMime( - purpose: CreateFlowUploadPurpose, - mime: string, -): boolean { - const m = mime.toLowerCase().split(";")[0]?.trim() ?? ""; - if (IMAGE_MIMES.has(m)) return true; - if (purpose === "customMethodAttachment" && CUSTOM_EXTRA_MIMES.has(m)) { - return true; - } - return false; -} - /** Extension including dot, from normalized mime (lowercase). */ export function extensionForMime(mime: string): string { const m = mime.toLowerCase().split(";")[0]?.trim() ?? ""; diff --git a/messages/en/create/upload.json b/messages/en/create/upload.json index d3d7673..2e46eb0 100644 --- a/messages/en/create/upload.json +++ b/messages/en/create/upload.json @@ -1,9 +1,13 @@ { "errors": { "generic": "Something went wrong while uploading. Try again.", - "tooLarge": "That file is too large. Try a smaller image or PDF.", + "tooLarge": "That file is too large. Community photos can be up to 5 MB; attachments up to 10 MB.", "unauthorized": "Sign in to upload files. Use Save progress if you started without an account.", - "misconfigured": "Uploads are not available on this server yet." + "misconfigured": "Uploads are not available on this server yet.", + "empty": "That file is empty. Choose a file that has content.", + "svg": "SVG files aren't supported. Export a JPEG, PNG, WebP, or GIF instead.", + "invalidType": "That file isn't a supported image (or PDF for attachments). Renaming the extension isn't enough.", + "undecodable": "We couldn't read that image. It may be damaged — try another file." }, "uploading": "Uploading…" } diff --git a/tests/components/UploadPage.test.tsx b/tests/components/UploadPage.test.tsx index 210a235..d420434 100644 --- a/tests/components/UploadPage.test.tsx +++ b/tests/components/UploadPage.test.tsx @@ -1,9 +1,71 @@ -import { describe, it, expect } from "vitest"; -import { renderWithProviders as render, screen } from "../utils/test-utils"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + renderWithProviders as render, + screen, + fireEvent, + cleanup, +} from "../utils/test-utils"; +import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom/vitest"; import { CommunityUploadScreen } from "../../app/(app)/create/screens/upload/CommunityUploadScreen"; +import { + clearPendingCommunityAvatarFile, + readPendingCommunityAvatarFile, + storePendingCommunityAvatarFile, +} from "../../lib/create/pendingCommunityAvatarUpload"; +import { fetchAuthSession } from "../../lib/create/api"; +import messages from "../../messages/en/index"; + +vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({ + readPendingCommunityAvatarFile: vi.fn().mockResolvedValue(null), + storePendingCommunityAvatarFile: vi.fn().mockResolvedValue(undefined), + clearPendingCommunityAvatarFile: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../lib/create/api", () => ({ + fetchAuthSession: vi.fn().mockResolvedValue({ user: null }), +})); + +const pngBytes = Uint8Array.from( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ), +); + +const copy = messages.create.community.communityUpload; +const uploadErrors = messages.create.upload.errors; + +function fileInput(): HTMLInputElement { + const input = document.querySelector('input[type="file"]'); + expect(input).toBeInstanceOf(HTMLInputElement); + return input as HTMLInputElement; +} describe("CommunityUploadScreen", () => { + beforeEach(() => { + vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(null); + vi.mocked(storePendingCommunityAvatarFile).mockResolvedValue(undefined); + vi.mocked(clearPendingCommunityAvatarFile).mockResolvedValue(undefined); + vi.mocked(fetchAuthSession).mockResolvedValue({ user: null }); + Object.defineProperty(URL, "createObjectURL", { + value: vi.fn(() => "blob:pending-avatar"), + writable: true, + configurable: true, + }); + Object.defineProperty(URL, "revokeObjectURL", { + value: vi.fn(), + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + cleanup(); + Reflect.deleteProperty(URL, "createObjectURL"); + Reflect.deleteProperty(URL, "revokeObjectURL"); + }); + it("renders HeaderLockup", () => { render(); expect( @@ -22,4 +84,76 @@ describe("CommunityUploadScreen", () => { ), ).toBeInTheDocument(); }); + + it("restores a pending IndexedDB photo and lets the user remove it", async () => { + const user = userEvent.setup(); + const pending = new File([pngBytes], "avatar.png", { type: "image/png" }); + vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(pending); + + render(); + + const preview = await screen.findByRole("img", { name: copy.previewAlt }); + expect(preview).toHaveAttribute("src", "blob:pending-avatar"); + + const remove = screen.getByRole("button", { + name: copy.clearPendingUploadAriaLabel, + }); + await user.click(remove); + + expect(clearPendingCommunityAvatarFile).toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument(); + expect( + screen.queryByRole("img", { name: copy.previewAlt }), + ).not.toBeInTheDocument(); + }); + + it("rejects an empty file with a clear error and does not stage it", async () => { + render(); + await screen.findByText(copy.signInToUploadNote); + + fireEvent.change(fileInput(), { + target: { files: [new File([], "empty.png", { type: "image/png" })] }, + }); + + expect(await screen.findByRole("alert")).toHaveTextContent( + uploadErrors.empty, + ); + expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled(); + }); + + it("rejects a text file renamed as PNG", async () => { + render(); + await screen.findByText(copy.signInToUploadNote); + + fireEvent.change(fileInput(), { + target: { + files: [new File(["hello"], "photo.png", { type: "image/png" })], + }, + }); + + expect(await screen.findByRole("alert")).toHaveTextContent( + uploadErrors.invalidType, + ); + expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled(); + }); + + it("rejects SVG uploads", async () => { + render(); + await screen.findByText(copy.signInToUploadNote); + + fireEvent.change(fileInput(), { + target: { + files: [ + new File( + [''], + "photo.png", + { type: "image/png" }, + ), + ], + }, + }); + + expect(await screen.findByRole("alert")).toHaveTextContent(uploadErrors.svg); + expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/createFlowUploadConstants.test.ts b/tests/unit/createFlowUploadConstants.test.ts index 50edd0f..ac46e3e 100644 --- a/tests/unit/createFlowUploadConstants.test.ts +++ b/tests/unit/createFlowUploadConstants.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { extensionForMime, - isAllowedMime, isValidUploadFileId, maxBytesForPurpose, } from "../../lib/server/uploads/uploadConstants"; @@ -12,18 +11,6 @@ describe("createFlow upload constants", () => { expect(maxBytesForPurpose("customMethodAttachment")).toBe(10 * 1024 * 1024); }); - it("isAllowedMime allows images for both purposes", () => { - expect(isAllowedMime("communityAvatar", "image/png")).toBe(true); - expect(isAllowedMime("customMethodAttachment", "image/jpeg")).toBe(true); - }); - - it("isAllowedMime allows pdf only for customMethodAttachment", () => { - expect(isAllowedMime("communityAvatar", "application/pdf")).toBe(false); - expect(isAllowedMime("customMethodAttachment", "application/pdf")).toBe( - true, - ); - }); - it("extensionForMime maps common types", () => { expect(extensionForMime("image/png")).toBe(".png"); expect(extensionForMime("image/jpeg")).toBe(".jpg"); diff --git a/tests/unit/createFlowUploadValidation.test.ts b/tests/unit/createFlowUploadValidation.test.ts new file mode 100644 index 0000000..18df4ab --- /dev/null +++ b/tests/unit/createFlowUploadValidation.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; +import { + CreateFlowUploadValidationError, + maxBytesForPurpose, + validateCreateFlowUploadBytes, + validateCreateFlowUploadFile, +} from "../../lib/create/createFlowUploadValidation"; +import { uploadCreateFlowFile } from "../../lib/create/uploadToServer"; + +/** 1×1 PNG (valid IHDR). */ +const PNG_1X1 = Uint8Array.from( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ), +); + +/** SOF0 1×1 JPEG (header-decodable; not necessarily a complete scan). */ +const JPEG_1X1_SOF = Uint8Array.from([ + 0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, + 0x11, 0x00, 0xff, 0xd9, +]); + +const GIF_1X1 = Uint8Array.from([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x3b, +]); + +/** VP8X 1×1 WebP. */ +const WEBP_1X1 = Uint8Array.from([ + 0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, + 0x50, 0x38, 0x58, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, +]); + +const PDF_STUB = new TextEncoder().encode("%PDF-1.4\n%%EOF\n"); + +const SVG_MARKUP = new TextEncoder().encode( + '', +); + +const XML_SVG = new TextEncoder().encode( + '', +); + +describe("validateCreateFlowUploadBytes", () => { + it("accepts a real PNG as a community avatar", () => { + const result = validateCreateFlowUploadBytes("communityAvatar", PNG_1X1); + expect(result).toEqual({ + ok: true, + kind: "png", + mimeType: "image/png", + }); + }); + + it("accepts JPEG, GIF, and WebP headers", () => { + expect(validateCreateFlowUploadBytes("communityAvatar", JPEG_1X1_SOF).ok).toBe( + true, + ); + expect(validateCreateFlowUploadBytes("communityAvatar", GIF_1X1).ok).toBe( + true, + ); + expect(validateCreateFlowUploadBytes("communityAvatar", WEBP_1X1).ok).toBe( + true, + ); + }); + + it("rejects an empty buffer", () => { + expect(validateCreateFlowUploadBytes("communityAvatar", new Uint8Array())).toEqual( + { ok: false, reason: "empty" }, + ); + }); + + it("rejects a file larger than the purpose cap", () => { + const oversized = new Uint8Array(maxBytesForPurpose("communityAvatar") + 1); + oversized.set(PNG_1X1.subarray(0, 8), 0); + expect(validateCreateFlowUploadBytes("communityAvatar", oversized)).toEqual({ + ok: false, + reason: "tooLarge", + }); + expect(maxBytesForPurpose("customMethodAttachment")).toBeGreaterThan( + maxBytesForPurpose("communityAvatar"), + ); + }); + + it("rejects text spoofed as PNG", () => { + expect( + validateCreateFlowUploadBytes( + "communityAvatar", + new TextEncoder().encode("hello world"), + ), + ).toEqual({ ok: false, reason: "invalidType" }); + }); + + it("rejects SVG markup, including xml-prefixed SVG", () => { + expect(validateCreateFlowUploadBytes("communityAvatar", SVG_MARKUP)).toEqual( + { ok: false, reason: "svg" }, + ); + expect(validateCreateFlowUploadBytes("customMethodAttachment", XML_SVG)).toEqual( + { ok: false, reason: "svg" }, + ); + }); + + it("rejects a PNG signature that cannot be decoded", () => { + const truncated = PNG_1X1.subarray(0, 8); + expect(validateCreateFlowUploadBytes("communityAvatar", truncated)).toEqual({ + ok: false, + reason: "undecodable", + }); + }); + + it("rejects PDF for community avatars and allows it for attachments", () => { + expect(validateCreateFlowUploadBytes("communityAvatar", PDF_STUB)).toEqual({ + ok: false, + reason: "invalidType", + }); + expect(validateCreateFlowUploadBytes("customMethodAttachment", PDF_STUB)).toEqual( + { + ok: true, + kind: "pdf", + mimeType: "application/pdf", + }, + ); + }); +}); + +describe("validateCreateFlowUploadFile", () => { + it("rejects empty files before reading bytes", async () => { + const file = new File([], "empty.png", { type: "image/png" }); + await expect( + validateCreateFlowUploadFile(file, "communityAvatar"), + ).resolves.toEqual({ ok: false, reason: "empty" }); + }); + + it("rejects SVG by name even when the MIME is spoofed", async () => { + const file = new File([PNG_1X1], "logo.svg", { type: "image/png" }); + await expect( + validateCreateFlowUploadFile(file, "communityAvatar"), + ).resolves.toEqual({ ok: false, reason: "svg" }); + }); + + it("rejects a 17MB file by size without treating it as a type error", async () => { + const file = new File([new Uint8Array([0x89, 0x50])], "huge.png", { + type: "image/png", + }); + Object.defineProperty(file, "size", { value: 17 * 1024 * 1024 }); + await expect( + validateCreateFlowUploadFile(file, "communityAvatar"), + ).resolves.toEqual({ ok: false, reason: "tooLarge" }); + }); +}); + +describe("uploadCreateFlowFile", () => { + it("does not POST when client validation fails", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const file = new File(["not an image"], "photo.png", { type: "image/png" }); + await expect( + uploadCreateFlowFile(file, "communityAvatar"), + ).rejects.toBeInstanceOf(CreateFlowUploadValidationError); + expect(fetchMock).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); +}); diff --git a/tests/unit/saveCreateFlowUpload.test.ts b/tests/unit/saveCreateFlowUpload.test.ts new file mode 100644 index 0000000..6644712 --- /dev/null +++ b/tests/unit/saveCreateFlowUpload.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getUploadRootFromEnvMock = vi.fn(); +let uploadRoot: string | null = null; + +vi.mock("../../lib/server/uploads/uploadRoot", async () => { + const actual = await vi.importActual< + typeof import("../../lib/server/uploads/uploadRoot") + >("../../lib/server/uploads/uploadRoot"); + return { + ...actual, + getUploadRootFromEnv: () => getUploadRootFromEnvMock(), + }; +}); + +import { saveCreateFlowUpload } from "../../lib/server/uploads/saveCreateFlowUpload"; + +const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", +); + +describe("saveCreateFlowUpload", () => { + beforeEach(async () => { + uploadRoot = await mkdtemp(path.join(tmpdir(), "cr-upload-save-")); + getUploadRootFromEnvMock.mockReset(); + getUploadRootFromEnvMock.mockImplementation(() => uploadRoot); + }); + + afterEach(() => { + uploadRoot = null; + }); + + it("writes a sniffed PNG even when the client declared the wrong MIME", async () => { + const saved = await saveCreateFlowUpload({ + purpose: "communityAvatar", + buffer: PNG_1X1, + }); + expect("error" in saved).toBe(false); + if ("error" in saved) return; + expect(saved.mimeType).toBe("image/png"); + const files = await readdir(uploadRoot!); + expect(files).toEqual([`${saved.id}.png`]); + }); + + it("rejects spoofed bytes without writing", async () => { + const saved = await saveCreateFlowUpload({ + purpose: "communityAvatar", + buffer: Buffer.from("not an image"), + }); + expect(saved).toEqual({ error: "validation", reason: "invalidType" }); + expect(await readdir(uploadRoot!)).toEqual([]); + }); +}); diff --git a/tests/unit/uploadsPostRoute.test.ts b/tests/unit/uploadsPostRoute.test.ts index 4cefbc9..16a42ba 100644 --- a/tests/unit/uploadsPostRoute.test.ts +++ b/tests/unit/uploadsPostRoute.test.ts @@ -27,6 +27,7 @@ function multipartRequest(opts: { purpose?: string; fileName?: string; fileContent?: string; + contentType?: string; }): NextRequest { const boundary = "----VitestBoundary"; const parts: string[] = []; @@ -36,8 +37,9 @@ function multipartRequest(opts: { ); } if (opts.fileName && opts.fileContent !== undefined) { + const contentType = opts.contentType ?? "image/png"; parts.push( - `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${opts.fileName}"\r\nContent-Type: image/png\r\n\r\n${opts.fileContent}\r\n`, + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${opts.fileName}"\r\nContent-Type: ${contentType}\r\n\r\n${opts.fileContent}\r\n`, ); } parts.push(`--${boundary}--\r\n`); @@ -56,6 +58,7 @@ beforeEach(() => { getUploadRootFromEnvMock.mockReset(); isDatabaseConfiguredMock.mockReturnValue(true); getUploadRootFromEnvMock.mockReturnValue("/tmp/uploads"); + getSessionUserMock.mockResolvedValue({ id: "u1", email: "a@b.c" }); }); describe("POST /api/uploads", () => { @@ -78,7 +81,6 @@ describe("POST /api/uploads", () => { }); it("returns 500 when UPLOAD_ROOT is unset", async () => { - getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" }); getUploadRootFromEnvMock.mockReturnValueOnce(null); const res = await POST( new NextRequest("https://x.test/api/uploads", { method: "POST" }), @@ -90,7 +92,6 @@ describe("POST /api/uploads", () => { }); it("returns 400 when purpose is missing", async () => { - getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" }); const res = await POST( multipartRequest({ fileName: "avatar.png", fileContent: "x" }), undefined, @@ -99,4 +100,56 @@ describe("POST /api/uploads", () => { const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("validation_error"); }); + + it("returns 400 for an empty file", async () => { + const res = await POST( + multipartRequest({ + purpose: "communityAvatar", + fileName: "empty.png", + fileContent: "", + }), + undefined, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { + error: { code: string }; + details?: { reason?: string }; + }; + expect(body.error.code).toBe("validation_error"); + expect(body.details?.reason).toBe("empty"); + }); + + it("returns 400 for SVG even when named .png", async () => { + const res = await POST( + multipartRequest({ + purpose: "communityAvatar", + fileName: "photo.png", + fileContent: '', + contentType: "image/png", + }), + undefined, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { + details?: { reason?: string }; + }; + expect(body.details?.reason).toBe("svg"); + }); + + it("returns 400 for a text file spoofed as PNG", async () => { + const res = await POST( + multipartRequest({ + purpose: "communityAvatar", + fileName: "photo.png", + fileContent: "not an image", + contentType: "image/png", + }), + undefined, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { + details?: { reason?: string }; + }; + expect(body.details?.reason).toBe("invalidType"); + }); }); -- 2.43.0