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
+18 -16
View File
@@ -2,12 +2,12 @@ import { writeFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import type { CreateFlowUploadPurpose } from "./uploadConstants";
import {
extensionForMime,
isAllowedMime,
maxBytesForPurpose,
} from "./uploadConstants";
import { extensionForMime } from "./uploadConstants";
import { ensureUploadRootExists, getUploadRootFromEnv } from "./uploadRoot";
import {
validateCreateFlowUploadBytes,
type CreateFlowUploadValidationReason,
} from "../../create/createFlowUploadValidation";
export type SaveCreateFlowUploadResult = {
/** Filename stem (UUID) without extension — used in GET URL. */
@@ -18,30 +18,32 @@ export type SaveCreateFlowUploadResult = {
byteLength: number;
};
export type SaveCreateFlowUploadFailure = {
error: "misconfigured" | "validation";
reason?: CreateFlowUploadValidationReason;
};
/**
* Writes bytes under `UPLOAD_ROOT/{id}{ext}` and returns a stable app URL path.
* Trusts sniffed bytes, not the client-declared MIME type.
*/
export async function saveCreateFlowUpload(params: {
purpose: CreateFlowUploadPurpose;
buffer: Buffer;
/** Declared MIME from the client `File.type` (validated server-side). */
mimeType: string;
}): Promise<SaveCreateFlowUploadResult | { error: "misconfigured" | "validation" }> {
}): Promise<SaveCreateFlowUploadResult | SaveCreateFlowUploadFailure> {
const root = getUploadRootFromEnv();
if (!root) {
return { error: "misconfigured" };
}
const { purpose, buffer, mimeType } = params;
if (buffer.length > maxBytesForPurpose(purpose)) {
return { error: "validation" };
}
if (!isAllowedMime(purpose, mimeType)) {
return { error: "validation" };
const { purpose, buffer } = params;
const validated = validateCreateFlowUploadBytes(purpose, buffer);
if (validated.ok === false) {
return { error: "validation", reason: validated.reason };
}
const id = randomUUID();
const ext = extensionForMime(mimeType);
const ext = extensionForMime(validated.mimeType);
const fileName = `${id}${ext}`;
const absolutePath = path.join(root, fileName);
@@ -51,7 +53,7 @@ export async function saveCreateFlowUpload(params: {
return {
id,
urlPath: `/api/uploads/${id}`,
mimeType: mimeType.toLowerCase().split(";")[0]?.trim() ?? "application/octet-stream",
mimeType: validated.mimeType,
byteLength: buffer.length,
};
}
+2 -31
View File
@@ -1,39 +1,10 @@
import type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose";
export type { CreateFlowUploadPurpose };
export type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose";
export { CREATE_FLOW_UPLOAD_PURPOSES } from "../../create/createFlowUploadPurpose";
export { maxBytesForPurpose } from "../../create/createFlowUploadValidation";
/** Max body size for multipart upload (bytes). */
export const CREATE_FLOW_UPLOAD_MAX_BYTES = 12 * 1024 * 1024;
const COMMUNITY_MAX = 5 * 1024 * 1024;
const CUSTOM_MAX = 10 * 1024 * 1024;
const IMAGE_MIMES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]);
const CUSTOM_EXTRA_MIMES = new Set(["application/pdf"]);
export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number {
return purpose === "communityAvatar" ? COMMUNITY_MAX : CUSTOM_MAX;
}
export function isAllowedMime(
purpose: CreateFlowUploadPurpose,
mime: string,
): boolean {
const m = mime.toLowerCase().split(";")[0]?.trim() ?? "";
if (IMAGE_MIMES.has(m)) return true;
if (purpose === "customMethodAttachment" && CUSTOM_EXTRA_MIMES.has(m)) {
return true;
}
return false;
}
/** Extension including dot, from normalized mime (lowercase). */
export function extensionForMime(mime: string): string {
const m = mime.toLowerCase().split(";")[0]?.trim() ?? "";