singularity-forge/web/app/api/terminal/upload/route.ts
2026-05-05 14:46:18 +02:00

98 lines
2.6 KiB
TypeScript

/**
* POST endpoint to upload an image file to the OS temp directory.
*
* POST /api/terminal/upload
* Body: multipart/form-data with a single `file` field
*
* Returns:
* 200 { ok: true, path: "/tmp/sf-upload-..." }
* 400 { error: "No file provided" }
* 413 { error: "File too large (...)" }
* 415 { error: "Unsupported image type: ..." }
* 500 { error: "Failed to write file: ..." }
*
* Observability:
* - Structured error responses with descriptive messages
* - No custom cleanup — OS handles temp dir cleanup on reboot
*/
import { randomBytes } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const ALLOWED_MIME_TYPES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
]);
const MIME_TO_EXT: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
};
/** 20 MB raw file size limit */
const MAX_FILE_SIZE = 20 * 1024 * 1024;
export async function POST(request: Request): Promise<Response> {
let formData: FormData;
try {
formData = await request.formData();
} catch {
return Response.json(
{ error: "Invalid multipart form data" },
{ status: 400 },
);
}
const file = formData.get("file");
if (!file || !(file instanceof File)) {
return Response.json({ error: "No file provided" }, { status: 400 });
}
// Validate MIME type
if (!ALLOWED_MIME_TYPES.has(file.type)) {
return Response.json(
{
error: `Unsupported image type: ${file.type || "unknown"}. Accepted: JPEG, PNG, GIF, WebP.`,
},
{ status: 415 },
);
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
return Response.json(
{ error: `File too large (${sizeMB} MB). Maximum: 20 MB.` },
{ status: 413 },
);
}
// Generate unique filename and write to temp dir
const ext = MIME_TO_EXT[file.type] ?? "bin";
const hex = randomBytes(4).toString("hex");
const filename = `sf-upload-${Date.now()}-${hex}.${ext}`;
const filePath = join(tmpdir(), filename);
try {
const arrayBuffer = await file.arrayBuffer();
await writeFile(filePath, Buffer.from(arrayBuffer));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("[terminal-upload] Failed to write file:", message);
return Response.json(
{ error: `Failed to write file: ${message}` },
{ status: 500 },
);
}
return Response.json({ ok: true, path: filePath });
}