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 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(); if (!user) { return unauthorized(); } if (!getUploadRootFromEnv()) { return serverMisconfigured( "File uploads are not configured (UPLOAD_ROOT is unset).", ); } const rl = rateLimitKey(`upload:${user.id}`, 5_000); if (rl.ok === false) { return rateLimited(rl.retryAfterMs); } let formData: FormData; try { formData = await request.formData(); } catch { return errorJson( "payload_too_large", "Upload body is too large or malformed.", 413, ); } const purposeRaw = formData.get("purpose"); const file = asUploadedBlob(formData.get("file")); 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, }); });