import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { PassThrough, Readable } from "node:stream"; import { createZipArchive } from "@/lib/create-zip"; import { drawIdText } from "@/lib/draw-id"; import { isPresetFont } from "@/lib/fonts"; import { CUSTOM_FONT_FAMILY, formatUserId, MAX_FONT_BYTES, MAX_IMAGE_BYTES, parseGenerateConfig, sanitizeFilename, toIdDrawStyle, } from "@/lib/id-config"; import { registerPresetFonts } from "@/lib/register-fonts"; export const runtime = "nodejs"; export const maxDuration = 300; export const dynamic = "force-dynamic"; function jsonError(message: string, status = 400): Response { return Response.json({ error: message }, { status }); } export async function POST(req: Request): Promise { let tmpDir: string | null = null; try { const form = await req.formData(); const imageFile = form.get("image"); const fontFile = form.get("font"); const configRaw = form.get("config"); if (!(imageFile instanceof File)) { return jsonError("A base image is required"); } if (imageFile.size === 0) { return jsonError("The base image is empty"); } if (imageFile.size > MAX_IMAGE_BYTES) { return jsonError("Base image must be 10MB or smaller"); } if (typeof configRaw !== "string") { return jsonError("Missing generate config"); } let config; try { config = parseGenerateConfig(JSON.parse(configRaw)); } catch (error) { const message = error instanceof Error ? error.message : "Invalid generate config"; return jsonError(message); } if (config.useCustomFont) { if (!(fontFile instanceof File) || fontFile.size === 0) { return jsonError("Custom font file is required when using a custom font"); } if (fontFile.size > MAX_FONT_BYTES) { return jsonError("Custom font must be 5MB or smaller"); } } else if (!isPresetFont(config.fontFamily)) { return jsonError("Unknown font family"); } tmpDir = await mkdtemp(path.join(tmpdir(), "kk-card-")); registerPresetFonts(); const style = toIdDrawStyle(config); if (config.useCustomFont && fontFile instanceof File) { const ext = fontFile.name.toLowerCase().endsWith(".otf") ? ".otf" : ".ttf"; const fontPath = path.join(tmpDir, `custom${ext}`); await writeFile(fontPath, Buffer.from(await fontFile.arrayBuffer())); GlobalFonts.registerFromPath(fontPath, CUSTOM_FONT_FAMILY); } const image = await loadImage( Buffer.from(await imageFile.arrayBuffer()), ); const canvas = createCanvas(image.width, image.height); const ctx = canvas.getContext("2d"); const passThrough = new PassThrough(); const archive = createZipArchive(); archive.on("error", (error: Error) => { passThrough.destroy(error); }); archive.pipe(passThrough); const usedNames = new Set(); const generate = async () => { try { for (let i = 0; i < config.count; i += 1) { const id = formatUserId( config.prefix, config.start + i, config.pad, config.suffix, ); let name = `${sanitizeFilename(id)}.png`; if (usedNames.has(name)) { name = `${sanitizeFilename(id)}-${i}.png`; } usedNames.add(name); ctx.drawImage(image, 0, 0); drawIdText(ctx, id, image.width, image.height, style); const png = await canvas.encode("png"); archive.append(Buffer.from(png), { name }); } await archive.finalize(); } catch (error) { archive.abort(); passThrough.destroy( error instanceof Error ? error : new Error("Failed to generate zip"), ); } finally { if (tmpDir) { await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } } }; void generate(); return new Response(Readable.toWeb(passThrough) as ReadableStream, { headers: { "Content-Type": "application/zip", "Content-Disposition": 'attachment; filename="ids.zip"', "Cache-Control": "no-store", }, }); } catch (error) { if (tmpDir) { await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } const message = error instanceof Error ? error.message : "Failed to generate images"; return jsonError(message, 500); } }