import { randomUUID } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; /** * Editor image uploads land in ./uploads/ (gitignored) and are served by * the GET /uploads/[name] route handler — Next.js only serves public/ * files that existed at build time, so runtime uploads need their own * route. Filenames are random UUIDs with an extension derived from the * MIME type — client filenames never touch the filesystem. Swapping this * for object storage later only means changing saveUploadedImage and the * returned URL. */ export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; // 8 MB const EXTENSION_BY_MIME: Record = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif", "image/avif": "avif", }; export const MIME_BY_EXTENSION: Record = Object.fromEntries( Object.entries(EXTENSION_BY_MIME).map(([mime, ext]) => [ext, mime]), ); /** Matches the filenames saveUploadedImage generates — nothing else. */ export const UPLOAD_NAME_PATTERN = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.(png|jpg|webp|gif|avif)$/; export function defaultUploadsDir(): string { return path.join(process.cwd(), "uploads"); } export type UploadResult = | { ok: true; filename: string } | { ok: false; error: string; status: 400 | 413 }; export async function saveUploadedImage(file: File): Promise { const extension = EXTENSION_BY_MIME[file.type]; if (!extension) { return { ok: false, status: 400, error: "Unsupported image type. Use PNG, JPEG, WebP, GIF, or AVIF.", }; } if (file.size > MAX_UPLOAD_BYTES) { return { ok: false, status: 413, error: `Image is too large (max ${MAX_UPLOAD_BYTES / 1024 / 1024} MB).`, }; } if (file.size === 0) { return { ok: false, status: 400, error: "The uploaded file is empty." }; } const filename = `${randomUUID()}.${extension}`; await mkdir(path.join(process.cwd(), "uploads"), { recursive: true }); await writeFile( path.join(process.cwd(), "uploads", filename), Buffer.from(await file.arrayBuffer()), ); return { ok: true, filename }; }