157 lines
4.2 KiB
TypeScript
157 lines
4.2 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 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,
|
|
});
|
|
});
|