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:
@@ -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;
|
||||
}
|
||||
|
||||
+6
-3
@@ -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);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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}
|
||||
/>
|
||||
|
||||
+4
-2
@@ -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);
|
||||
}
|
||||
|
||||
+2
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 } },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user