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 { 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;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+4
-2
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -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 resolveUploadError = useCallback(
|
const serverAvatarUrl = hasCommunityAvatarUrl(state.communityAvatarUrl)
|
||||||
(err: unknown) => {
|
? state.communityAvatarUrl!.trim()
|
||||||
if (err instanceof UploadToServerError) {
|
: null;
|
||||||
if (err.status === 413) return tUpload("errors.tooLarge");
|
const serverAvatarUrlRef = useRef(serverAvatarUrl);
|
||||||
if (err.status === 401) return tUpload("errors.unauthorized");
|
serverAvatarUrlRef.current = serverAvatarUrl;
|
||||||
if (err.code === "server_misconfigured") {
|
|
||||||
return tUpload("errors.misconfigured");
|
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],
|
[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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() ?? "";
|
||||||
|
|||||||
@@ -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…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user