yap-blog/src/lib/uploads.ts
2026-07-02 21:32:33 -04:00

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