Compare commits

..
2 Commits
18 changed files with 1072 additions and 112 deletions
@@ -2,7 +2,10 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useCreateFlow } from "../context/CreateFlowContext"; import { useCreateFlow } from "../context/CreateFlowContext";
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer"; import {
CreateFlowUploadValidationError,
uploadCreateFlowFile,
} from "../../../../lib/create/uploadToServer";
import { import {
clearPendingCommunityAvatarFile, clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile, readPendingCommunityAvatarFile,
@@ -19,13 +22,17 @@ export function CreateFlowPendingAvatarFlush({
sessionUser: { id: string; email: string } | null | undefined; sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean; sessionResolved: boolean;
}) { }) {
const { updateState } = useCreateFlow(); const { state, updateState } = useCreateFlow();
/** One successful flush per signed-in user id (survives React StrictMode remounts). */ /** One successful flush per signed-in user id (survives React StrictMode remounts). */
const lastFlushedUserIdRef = useRef<string | null>(null); const lastFlushedUserIdRef = useRef<string | null>(null);
const hasServerAvatar =
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0;
useEffect(() => { useEffect(() => {
if (!sessionResolved || !sessionUser) return; if (!sessionResolved || !sessionUser) return;
if (lastFlushedUserIdRef.current === sessionUser.id) return; if (lastFlushedUserIdRef.current === sessionUser.id) return;
if (hasServerAvatar) return;
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
@@ -37,15 +44,18 @@ export function CreateFlowPendingAvatarFlush({
await clearPendingCommunityAvatarFile(); await clearPendingCommunityAvatarFile();
updateState({ communityAvatarUrl: url }); updateState({ communityAvatarUrl: url });
lastFlushedUserIdRef.current = sessionUser.id; lastFlushedUserIdRef.current = sessionUser.id;
} catch { } catch (err) {
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT. if (err instanceof CreateFlowUploadValidationError) {
await clearPendingCommunityAvatarFile();
}
// Leave a transient (auth / UPLOAD_ROOT) failure in place to retry.
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [sessionResolved, sessionUser, updateState]); }, [hasServerAvatar, sessionResolved, sessionUser, updateState]);
return null; return null;
} }
@@ -3,7 +3,10 @@
import { memo, useCallback, useRef, useState } from "react"; import { memo, useCallback, useRef, useState } from "react";
import { useTranslation } from "../../../../contexts/MessagesContext"; import { useTranslation } from "../../../../contexts/MessagesContext";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; 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 { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view";
import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types"; import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types";
@@ -68,8 +71,8 @@ function CustomMethodCardUploadBlockRowContainerComponent({
: b, : b,
), ),
); );
} catch { } catch (err) {
setErrorMessage(tUpload("errors.generic")); setErrorMessage(tUpload(createFlowUploadFailureMessageKey(err)));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -4,6 +4,7 @@ import { memo } from "react";
import Upload from "../../../../components/controls/Upload"; import Upload from "../../../../components/controls/Upload";
import InputLabel from "../../../../components/type/InputLabel"; import InputLabel from "../../../../components/type/InputLabel";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types"; import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types";
function CustomMethodCardUploadBlockRowViewComponent({ function CustomMethodCardUploadBlockRowViewComponent({
@@ -43,7 +44,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
type="file" type="file"
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf" accept={CUSTOM_ATTACHMENT_ACCEPT}
aria-label={uploadFileInputAriaLabel} aria-label={uploadFileInputAriaLabel}
onChange={onFileInputChange} onChange={onFileInputChange}
/> />
@@ -8,6 +8,7 @@ import {
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { createFlowUploadFailureMessageKey } from "../../../../../lib/create/uploadToServer";
import { import {
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS, CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS, CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS,
@@ -462,8 +463,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
try { try {
const { url } = await onPersistCustomUploadFile(file); const { url } = await onPersistCustomUploadFile(file);
setUploadAssetUrl(url); setUploadAssetUrl(url);
} catch { } catch (err) {
setUploadFieldError(tUpload("errors.generic")); setUploadFileName(undefined);
setUploadFieldError(tUpload(createFlowUploadFailureMessageKey(err)));
} finally { } finally {
setUploadFieldBusy(false); setUploadFieldBusy(false);
} }
@@ -10,6 +10,7 @@ import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import InputLabel from "../../../../components/type/InputLabel"; import InputLabel from "../../../../components/type/InputLabel";
import ApplicableScopeField from "../ApplicableScopeField"; import ApplicableScopeField from "../ApplicableScopeField";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants"; 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"; import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
const TEXT_PLACEHOLDER_MAX = 8000; const TEXT_PLACEHOLDER_MAX = 8000;
@@ -119,6 +120,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
type="file" type="file"
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept={CUSTOM_ATTACHMENT_ACCEPT}
aria-label={copy.upload.uploadFileInputAriaLabel} aria-label={copy.upload.uploadFileInputAriaLabel}
onChange={onFileChosen} onChange={onFileChosen}
/> />
@@ -16,14 +16,24 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
import { fetchAuthSession } from "../../../../../lib/create/api"; import { fetchAuthSession } from "../../../../../lib/create/api";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { import {
UploadToServerError, createFlowUploadFailureMessageKey,
uploadCreateFlowFile, uploadCreateFlowFile,
} from "../../../../../lib/create/uploadToServer"; } from "../../../../../lib/create/uploadToServer";
import {
COMMUNITY_AVATAR_ACCEPT,
messageKeyForCreateFlowUploadReason,
validateCreateFlowUploadFile,
} from "../../../../../lib/create/createFlowUploadValidation";
import { import {
clearPendingCommunityAvatarFile, clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
storePendingCommunityAvatarFile, storePendingCommunityAvatarFile,
} from "../../../../../lib/create/pendingCommunityAvatarUpload"; } 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`. */ /** Create Community — Figma Flow — Upload `20094:41524`. */
export function CommunityUploadScreen() { export function CommunityUploadScreen() {
const m = useMessages(); const m = useMessages();
@@ -54,17 +64,57 @@ export function CommunityUploadScreen() {
[localPreviewUrl], [localPreviewUrl],
); );
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 () => {
cancelled = true;
};
}, []);
const resolveUploadError = useCallback( const resolveUploadError = useCallback(
(err: unknown) => { (err: unknown) => tUpload(createFlowUploadFailureMessageKey(err)),
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");
}
}
return tUpload("errors.generic");
},
[tUpload], [tUpload],
); );
@@ -94,6 +144,16 @@ export function CommunityUploadScreen() {
} }
if (signedIn === false) { if (signedIn === false) {
const validated = await validateCreateFlowUploadFile(
file,
"communityAvatar",
);
if (validated.ok === false) {
setErrorMessage(
tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
);
return;
}
try { try {
await storePendingCommunityAvatarFile(file); await storePendingCommunityAvatarFile(file);
setLocalPreviewUrl((prev) => { setLocalPreviewUrl((prev) => {
@@ -121,24 +181,16 @@ export function CommunityUploadScreen() {
if (prev) URL.revokeObjectURL(prev); if (prev) URL.revokeObjectURL(prev);
return null; return null;
}); });
if ( if (hasCommunityAvatarUrl(state.communityAvatarUrl)) {
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
) {
updateState({ communityAvatarUrl: undefined }); updateState({ communityAvatarUrl: undefined });
} }
// Clear any anonymous staged blob so the post-sign-in flush won't resurrect it.
void clearPendingCommunityAvatarFile(); void clearPendingCommunityAvatarFile();
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = "";
} }
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]); }, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
const displaySrc = const displaySrc = serverAvatarUrl ?? localPreviewUrl;
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim()
: localPreviewUrl;
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0; const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
return ( return (
@@ -161,7 +213,7 @@ export function CommunityUploadScreen() {
type="file" type="file"
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif" accept={COMMUNITY_AVATAR_ACCEPT}
aria-label={u.hintText} aria-label={u.hintText}
onChange={handleFileChange} onChange={handleFileChange}
/> />
+52 -7
View File
@@ -14,13 +14,40 @@ import { saveCreateFlowUpload } from "../../../lib/server/uploads/saveCreateFlow
import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot"; import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot";
import { import {
CREATE_FLOW_UPLOAD_MAX_BYTES, CREATE_FLOW_UPLOAD_MAX_BYTES,
maxBytesForPurpose,
type CreateFlowUploadPurpose, type CreateFlowUploadPurpose,
} from "../../../lib/server/uploads/uploadConstants"; } 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<Blob>;
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 { function isPurpose(x: string): x is CreateFlowUploadPurpose {
return x === "communityAvatar" || x === "customMethodAttachment"; 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) => { export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
if (!isDatabaseConfigured()) { if (!isDatabaseConfigured()) {
return dbUnavailable(); return dbUnavailable();
@@ -54,7 +81,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
} }
const purposeRaw = formData.get("purpose"); const purposeRaw = formData.get("purpose");
const file = formData.get("file"); const file = asUploadedBlob(formData.get("file"));
if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) { if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) {
return errorJson( return errorJson(
@@ -64,7 +91,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
); );
} }
if (!(file instanceof File)) { if (!file) {
return errorJson( return errorJson(
"validation_error", "validation_error",
"Missing `file` field (multipart file).", "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( return errorJson(
"payload_too_large", "payload_too_large",
`File exceeds maximum allowed size (${CREATE_FLOW_UPLOAD_MAX_BYTES} bytes).`, messageForValidationReason("tooLarge"),
413, 413,
{ details: { reason: "tooLarge" } },
); );
} }
const buf = Buffer.from(await file.arrayBuffer()); const buf = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || "application/octet-stream";
const saved = await saveCreateFlowUpload({ const saved = await saveCreateFlowUpload({
purpose: purposeRaw, purpose: purposeRaw,
buffer: buf, buffer: buf,
mimeType,
}); });
if ("error" in saved) { 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).", "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( return errorJson(
"validation_error", "validation_error",
"File type or size is not allowed for this upload purpose.", messageForValidationReason(reason),
400, 400,
{ details: { reason } },
); );
} }
+402
View File
@@ -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("<svg") || head.includes("<!doctype svg");
}
/**
* True-type sniff from magic bytes. Raster/PDF signatures win over a later
* `<svg` substring so binary comments cannot be classified as SVG.
*/
function sniffCreateFlowUploadBytes(
bytes: Uint8Array,
): SniffedUploadKind {
if (bytes.length === 0) return "empty";
if (bytes.length >= 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<Uint8Array> {
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<boolean> {
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<CreateFlowUploadValidationResult> {
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;
}
+15 -2
View File
@@ -22,7 +22,20 @@ function openDb(): Promise<IDBDatabase> {
}); });
} }
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<void> { export async function storePendingCommunityAvatarFile(file: File): Promise<void> {
if (typeof indexedDB === "undefined") {
throw new Error("indexedDB is not available");
}
const db = await openDb(); const db = await openDb();
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -38,6 +51,7 @@ export async function storePendingCommunityAvatarFile(file: File): Promise<void>
/** Read staged file without removing it (caller clears after successful upload). */ /** Read staged file without removing it (caller clears after successful upload). */
export async function readPendingCommunityAvatarFile(): Promise<File | null> { export async function readPendingCommunityAvatarFile(): Promise<File | null> {
if (typeof indexedDB === "undefined") return null;
const db = await openDb(); const db = await openDb();
try { try {
return await new Promise<File | null>((resolve, reject) => { return await new Promise<File | null>((resolve, reject) => {
@@ -45,8 +59,7 @@ export async function readPendingCommunityAvatarFile(): Promise<File | null> {
tx.onerror = () => reject(tx.error ?? new Error("indexedDB read failed")); tx.onerror = () => reject(tx.error ?? new Error("indexedDB read failed"));
const getReq = tx.objectStore(STORE).get(KEY); const getReq = tx.objectStore(STORE).get(KEY);
getReq.onsuccess = () => { getReq.onsuccess = () => {
const v = getReq.result; resolve(coerceToFile(getReq.result));
resolve(v instanceof File ? v : null);
}; };
getReq.onerror = () => reject(getReq.error); getReq.onerror = () => reject(getReq.error);
}); });
+62 -4
View File
@@ -1,4 +1,10 @@
import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose"; import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose";
import {
CreateFlowUploadValidationError,
messageKeyForCreateFlowUploadReason,
validateCreateFlowUploadFile,
type CreateFlowUploadValidationReason,
} from "./createFlowUploadValidation";
export type UploadToServerResult = { export type UploadToServerResult = {
url: string; url: string;
@@ -7,6 +13,20 @@ export type UploadToServerResult = {
byteLength: number; byteLength: number;
}; };
const VALIDATION_REASONS = new Set<CreateFlowUploadValidationReason>([
"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`. * Authenticated multipart upload to `POST /api/uploads`.
* Caller must have a session cookie (same-origin fetch). * Caller must have a session cookie (same-origin fetch).
@@ -15,6 +35,11 @@ export async function uploadCreateFlowFile(
file: File, file: File,
purpose: CreateFlowUploadPurpose, purpose: CreateFlowUploadPurpose,
): Promise<UploadToServerResult> { ): Promise<UploadToServerResult> {
const validated = await validateCreateFlowUploadFile(file, purpose);
if (validated.ok === false) {
throw new CreateFlowUploadValidationError(validated.reason);
}
const formData = new FormData(); const formData = new FormData();
formData.append("purpose", purpose); formData.append("purpose", purpose);
formData.append("file", file); formData.append("file", file);
@@ -36,14 +61,24 @@ export async function uploadCreateFlowFile(
if (body && typeof body === "object" && "error" in body) { if (body && typeof body === "object" && "error" in body) {
const e = (body as { const e = (body as {
error?: { message?: string; code?: string }; error?: { message?: string; code?: string };
details?: { reason?: unknown };
}).error; }).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 { return {
message: typeof e.message === "string" ? e.message : null, message: typeof e.message === "string" ? e.message : null,
code: typeof e.code === "string" ? e.code : 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) { if (!res.ok) {
@@ -54,7 +89,7 @@ export async function uploadCreateFlowFile(
? "UNAUTHORIZED" ? "UNAUTHORIZED"
: "UPLOAD_FAILED"; : "UPLOAD_FAILED";
const code = errParts.code ?? errParts.message ?? fallback; 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 { const data = body as {
@@ -83,11 +118,34 @@ export async function uploadCreateFlowFile(
export class UploadToServerError extends Error { export class UploadToServerError extends Error {
readonly status: number; readonly status: number;
readonly code: string; readonly code: string;
readonly reason: CreateFlowUploadValidationReason | null;
constructor(status: number, code: string) { constructor(
status: number,
code: string,
reason: CreateFlowUploadValidationReason | null = null,
) {
super(code); super(code);
this.name = "UploadToServerError"; this.name = "UploadToServerError";
this.status = status; this.status = status;
this.code = code; 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 };
+18 -16
View File
@@ -2,12 +2,12 @@ import { writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { CreateFlowUploadPurpose } from "./uploadConstants"; import type { CreateFlowUploadPurpose } from "./uploadConstants";
import { import { extensionForMime } from "./uploadConstants";
extensionForMime,
isAllowedMime,
maxBytesForPurpose,
} from "./uploadConstants";
import { ensureUploadRootExists, getUploadRootFromEnv } from "./uploadRoot"; import { ensureUploadRootExists, getUploadRootFromEnv } from "./uploadRoot";
import {
validateCreateFlowUploadBytes,
type CreateFlowUploadValidationReason,
} from "../../create/createFlowUploadValidation";
export type SaveCreateFlowUploadResult = { export type SaveCreateFlowUploadResult = {
/** Filename stem (UUID) without extension — used in GET URL. */ /** Filename stem (UUID) without extension — used in GET URL. */
@@ -18,30 +18,32 @@ export type SaveCreateFlowUploadResult = {
byteLength: number; byteLength: number;
}; };
export type SaveCreateFlowUploadFailure = {
error: "misconfigured" | "validation";
reason?: CreateFlowUploadValidationReason;
};
/** /**
* Writes bytes under `UPLOAD_ROOT/{id}{ext}` and returns a stable app URL path. * 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: { export async function saveCreateFlowUpload(params: {
purpose: CreateFlowUploadPurpose; purpose: CreateFlowUploadPurpose;
buffer: Buffer; buffer: Buffer;
/** Declared MIME from the client `File.type` (validated server-side). */ }): Promise<SaveCreateFlowUploadResult | SaveCreateFlowUploadFailure> {
mimeType: string;
}): Promise<SaveCreateFlowUploadResult | { error: "misconfigured" | "validation" }> {
const root = getUploadRootFromEnv(); const root = getUploadRootFromEnv();
if (!root) { if (!root) {
return { error: "misconfigured" }; return { error: "misconfigured" };
} }
const { purpose, buffer, mimeType } = params; const { purpose, buffer } = params;
if (buffer.length > maxBytesForPurpose(purpose)) { const validated = validateCreateFlowUploadBytes(purpose, buffer);
return { error: "validation" }; if (validated.ok === false) {
} return { error: "validation", reason: validated.reason };
if (!isAllowedMime(purpose, mimeType)) {
return { error: "validation" };
} }
const id = randomUUID(); const id = randomUUID();
const ext = extensionForMime(mimeType); const ext = extensionForMime(validated.mimeType);
const fileName = `${id}${ext}`; const fileName = `${id}${ext}`;
const absolutePath = path.join(root, fileName); const absolutePath = path.join(root, fileName);
@@ -51,7 +53,7 @@ export async function saveCreateFlowUpload(params: {
return { return {
id, id,
urlPath: `/api/uploads/${id}`, urlPath: `/api/uploads/${id}`,
mimeType: mimeType.toLowerCase().split(";")[0]?.trim() ?? "application/octet-stream", mimeType: validated.mimeType,
byteLength: buffer.length, byteLength: buffer.length,
}; };
} }
+2 -31
View File
@@ -1,39 +1,10 @@
import type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose"; export type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose";
export type { CreateFlowUploadPurpose };
export { CREATE_FLOW_UPLOAD_PURPOSES } from "../../create/createFlowUploadPurpose"; export { CREATE_FLOW_UPLOAD_PURPOSES } from "../../create/createFlowUploadPurpose";
export { maxBytesForPurpose } from "../../create/createFlowUploadValidation";
/** Max body size for multipart upload (bytes). */ /** Max body size for multipart upload (bytes). */
export const CREATE_FLOW_UPLOAD_MAX_BYTES = 12 * 1024 * 1024; 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). */ /** Extension including dot, from normalized mime (lowercase). */
export function extensionForMime(mime: string): string { export function extensionForMime(mime: string): string {
const m = mime.toLowerCase().split(";")[0]?.trim() ?? ""; const m = mime.toLowerCase().split(";")[0]?.trim() ?? "";
+6 -2
View File
@@ -1,9 +1,13 @@
{ {
"errors": { "errors": {
"generic": "Something went wrong while uploading. Try again.", "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.", "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…" "uploading": "Uploading…"
} }
+136 -2
View File
@@ -1,9 +1,71 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils"; 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 "@testing-library/jest-dom/vitest";
import { CommunityUploadScreen } from "../../app/(app)/create/screens/upload/CommunityUploadScreen"; 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", () => { 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", () => { it("renders HeaderLockup", () => {
render(<CommunityUploadScreen />); render(<CommunityUploadScreen />);
expect( expect(
@@ -22,4 +84,76 @@ describe("CommunityUploadScreen", () => {
), ),
).toBeInTheDocument(); ).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(<CommunityUploadScreen />);
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(<CommunityUploadScreen />);
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(<CommunityUploadScreen />);
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(<CommunityUploadScreen />);
await screen.findByText(copy.signInToUploadNote);
fireEvent.change(fileInput(), {
target: {
files: [
new File(
['<svg xmlns="http://www.w3.org/2000/svg"></svg>'],
"photo.png",
{ type: "image/png" },
),
],
},
});
expect(await screen.findByRole("alert")).toHaveTextContent(uploadErrors.svg);
expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled();
});
}); });
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
extensionForMime, extensionForMime,
isAllowedMime,
isValidUploadFileId, isValidUploadFileId,
maxBytesForPurpose, maxBytesForPurpose,
} from "../../lib/server/uploads/uploadConstants"; } from "../../lib/server/uploads/uploadConstants";
@@ -12,18 +11,6 @@ describe("createFlow upload constants", () => {
expect(maxBytesForPurpose("customMethodAttachment")).toBe(10 * 1024 * 1024); 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", () => { it("extensionForMime maps common types", () => {
expect(extensionForMime("image/png")).toBe(".png"); expect(extensionForMime("image/png")).toBe(".png");
expect(extensionForMime("image/jpeg")).toBe(".jpg"); expect(extensionForMime("image/jpeg")).toBe(".jpg");
@@ -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(
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>',
);
const XML_SVG = new TextEncoder().encode(
'<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"/>',
);
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();
});
});
+57
View File
@@ -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([]);
});
});
+56 -3
View File
@@ -27,6 +27,7 @@ function multipartRequest(opts: {
purpose?: string; purpose?: string;
fileName?: string; fileName?: string;
fileContent?: string; fileContent?: string;
contentType?: string;
}): NextRequest { }): NextRequest {
const boundary = "----VitestBoundary"; const boundary = "----VitestBoundary";
const parts: string[] = []; const parts: string[] = [];
@@ -36,8 +37,9 @@ function multipartRequest(opts: {
); );
} }
if (opts.fileName && opts.fileContent !== undefined) { if (opts.fileName && opts.fileContent !== undefined) {
const contentType = opts.contentType ?? "image/png";
parts.push( 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`); parts.push(`--${boundary}--\r\n`);
@@ -56,6 +58,7 @@ beforeEach(() => {
getUploadRootFromEnvMock.mockReset(); getUploadRootFromEnvMock.mockReset();
isDatabaseConfiguredMock.mockReturnValue(true); isDatabaseConfiguredMock.mockReturnValue(true);
getUploadRootFromEnvMock.mockReturnValue("/tmp/uploads"); getUploadRootFromEnvMock.mockReturnValue("/tmp/uploads");
getSessionUserMock.mockResolvedValue({ id: "u1", email: "a@b.c" });
}); });
describe("POST /api/uploads", () => { describe("POST /api/uploads", () => {
@@ -78,7 +81,6 @@ describe("POST /api/uploads", () => {
}); });
it("returns 500 when UPLOAD_ROOT is unset", async () => { it("returns 500 when UPLOAD_ROOT is unset", async () => {
getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" });
getUploadRootFromEnvMock.mockReturnValueOnce(null); getUploadRootFromEnvMock.mockReturnValueOnce(null);
const res = await POST( const res = await POST(
new NextRequest("https://x.test/api/uploads", { method: "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 () => { it("returns 400 when purpose is missing", async () => {
getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" });
const res = await POST( const res = await POST(
multipartRequest({ fileName: "avatar.png", fileContent: "x" }), multipartRequest({ fileName: "avatar.png", fileContent: "x" }),
undefined, undefined,
@@ -99,4 +100,56 @@ describe("POST /api/uploads", () => {
const body = (await res.json()) as { error: { code: string } }; const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe("validation_error"); 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: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
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");
});
}); });