Files
community-rule/app/api/uploads/route.ts
T
adilallo 8212423ec3 Keep a guest community logo on review and on the published rule.
Sign-in was clearing the staged file before it uploaded, and review only read the server URL, so a chosen logo disappeared after the upload step.
2026-11-05 20:02:40 -07:00

181 lines
4.9 KiB
TypeScript

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<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 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,
});
});