import { NextRequest, NextResponse } from "next/server"; import { isDatabaseConfigured } from "../../../lib/server/env"; import { dbUnavailable, errorJson, serverMisconfigured, unauthorized, rateLimited, } from "../../../lib/server/responses"; import { getSessionUser } from "../../../lib/server/session"; import { apiRoute } from "../../../lib/server/apiRoute"; import { rateLimitKey } from "../../../lib/server/rateLimit"; import { saveCreateFlowUpload } from "../../../lib/server/uploads/saveCreateFlowUpload"; 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; 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 clientIp(request: NextRequest): string { return ( request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip") ?? "unknown" ); } /** Same spacing as a signed-in upload, keyed by IP so a guest cannot fill the disk. */ const ANONYMOUS_COMMUNITY_AVATAR_MIN_INTERVAL_MS = 5_000; 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(); } const user = await getSessionUser(); let formData: FormData | null = null; try { formData = await request.formData(); } catch { formData = null; } const purposeRaw = formData?.get("purpose"); // A guest publishes before they have a session. Only the community logo // can be stored without one; attachments stay signed-in. A body that // cannot be parsed is not a logo upload. if (!user && purposeRaw !== "communityAvatar") { return unauthorized(); } if (!getUploadRootFromEnv()) { return serverMisconfigured( "File uploads are not configured (UPLOAD_ROOT is unset).", ); } if (!formData) { return errorJson( "payload_too_large", "Upload body is too large or malformed.", 413, ); } const file = asUploadedBlob(formData.get("file")); const rl = user ? rateLimitKey(`upload:${user.id}`, 5_000) : rateLimitKey( `upload-anon:${clientIp(request)}`, ANONYMOUS_COMMUNITY_AVATAR_MIN_INTERVAL_MS, ); if (rl.ok === false) { return rateLimited(rl.retryAfterMs); } if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) { return errorJson( "validation_error", "Invalid or missing `purpose` (expected communityAvatar | customMethodAttachment).", 400, ); } if (!file) { return errorJson( "validation_error", "Missing `file` field (multipart file).", 400, ); } 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", messageForValidationReason("tooLarge"), 413, { details: { reason: "tooLarge" } }, ); } const buf = Buffer.from(await file.arrayBuffer()); const saved = await saveCreateFlowUpload({ purpose: purposeRaw, buffer: buf, }); if ("error" in saved) { if (saved.error === "misconfigured") { return serverMisconfigured( "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", messageForValidationReason(reason), 400, { details: { reason } }, ); } return NextResponse.json({ url: saved.urlPath, id: saved.id, mimeType: saved.mimeType, byteLength: saved.byteLength, }); });