68 lines
2.2 KiB
TypeScript
68 lines
2.2 KiB
TypeScript
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<string, string> = {
|
|
"image/png": "png",
|
|
"image/jpeg": "jpg",
|
|
"image/webp": "webp",
|
|
"image/gif": "gif",
|
|
"image/avif": "avif",
|
|
};
|
|
|
|
export const MIME_BY_EXTENSION: Record<string, string> = 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<UploadResult> {
|
|
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 };
|
|
}
|