Restore the community photo after reload and reject empty, oversized, SVG, and spoofed uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-09-10 15:50:32 -06:00
co-authored by Cursor
parent 6ccc1e8c8e
commit 234f3998ad
18 changed files with 1072 additions and 112 deletions
@@ -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<string | null>(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;
}
@@ -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);
}
@@ -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}
/>
@@ -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<CustomMethodCardWizardProps>(
try {
const { url } = await onPersistCustomUploadFile(file);
setUploadAssetUrl(url);
} catch {
setUploadFieldError(tUpload("errors.generic"));
} catch (err) {
setUploadFileName(undefined);
setUploadFieldError(tUpload(createFlowUploadFailureMessageKey(err)));
} finally {
setUploadFieldBusy(false);
}
@@ -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}
/>
@@ -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}
/>
+52 -7
View File
@@ -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<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 {
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 } },
);
}