Files
SnapOtter/apps/api/src/routes/tools/qr-generate.ts
T

113 lines
3.4 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import QRCode from "qrcode";
import sharp from "sharp";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import { putObject } from "../../lib/object-storage.js";
const settingsSchema = z.object({
text: z.string().min(1).max(2000),
size: z.number().min(100).max(10000).default(400),
errorCorrection: z.enum(["L", "M", "Q", "H"]).default("M"),
foreground: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#000000"),
background: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#FFFFFF"),
logoDataUri: z
.string()
.regex(/^data:image\/(png|jpeg);base64,[A-Za-z0-9+/=]+$/)
.max(700000)
.optional(),
});
/**
* QR code generator - custom route (not factory) since it generates
* images from text input, not from uploaded files.
*/
export function registerQrGenerate(app: FastifyInstance) {
app.post("/api/v1/tools/qr-generate", async (request: FastifyRequest, reply: FastifyReply) => {
let body: unknown;
try {
body = request.body;
} catch {
return reply.status(400).send({ error: "Invalid request body" });
}
const result = settingsSchema.safeParse(body);
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: formatZodErrors(result.error.issues),
});
}
const settings = result.data;
try {
// When a logo is present, force max error correction so the QR
// remains scannable despite the logo occluding the center.
const ecLevel = settings.logoDataUri ? "H" : settings.errorCorrection;
let buffer = await QRCode.toBuffer(settings.text, {
width: settings.size,
errorCorrectionLevel: ecLevel,
color: {
dark: settings.foreground,
light: settings.background,
},
type: "png",
margin: 2,
});
if (settings.logoDataUri) {
// Decode the data-URI base64 payload into a buffer
const base64Part = settings.logoDataUri.split(",")[1];
let logoBuffer: Buffer;
try {
logoBuffer = Buffer.from(base64Part, "base64");
// Validate that sharp can decode it
await sharp(logoBuffer).metadata();
} catch {
return reply.status(400).send({ error: "Invalid logo image" });
}
// Resize logo to 22% of QR size and composite centered
const logoSize = Math.round(settings.size * 0.22);
const resizedLogo = await sharp(logoBuffer)
.resize(logoSize, logoSize, {
fit: "contain",
background: { r: 255, g: 255, b: 255, alpha: 0 },
})
.png()
.toBuffer();
buffer = await sharp(buffer)
.composite([{ input: resizedLogo, gravity: "centre" }])
.png()
.toBuffer();
}
const jobId = randomUUID();
const filename = "qrcode.png";
await putObject(`outputs/${jobId}/${filename}`, buffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: 0,
processedSize: buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "QR code generation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}