import "server-only"; import { z } from "zod"; import { prisma } from "@/lib/db"; import { requireAvailableQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; import { QR_TYPE_IDS, QrRequestError, QrTypeError, type QrTypeModule, } from "@/lib/qr/type-module"; import { shortUrl } from "@/lib/env"; import { isValidSlug } from "@/lib/slug"; /** * Platzhalter-Slug für die Vorschau eines noch nicht gespeicherten * dynamischen Codes. Gleiche Länge wie ein echter Slug, damit die Vorschau * die spätere Modul-Dichte korrekt zeigt. */ export const PREVIEW_SLUG = "vorschau"; export const qrModeSchema = z.enum(["static", "dynamic"], { error: "Ungültiger Modus.", }); export type QrMode = z.infer; export const qrRequestSchema = z.object({ type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }), data: z.record(z.string(), z.unknown()), design: designSchema, mode: qrModeSchema.default("static"), /** Slug eines bereits gespeicherten dynamischen Codes. */ slug: z.string().trim().optional(), /** Domain, unter der die Kurz-URL entsteht. */ domainId: z.string().trim().optional(), /** Name des Codes — bestimmt den Dateinamen beim Herunterladen. */ label: z.string().trim().max(120).optional(), }); export type QrRequest = z.infer; // Liegt in type-module.ts, damit auch availability.ts ihn werfen kann, ohne // dass ein Ringschluss zwischen den beiden Modulen entsteht. export { QrRequestError }; /** * Prüft, ob sich aus diesen Daten überhaupt ein Weiterleitungsziel bilden * lässt — und zwar, indem es einmal gebildet wird. * * `supportsDynamic` ist eine Eigenschaft des Typs, aber nicht jede Eingabe * eines dynamikfähigen Typs taugt dafür: ein GiroCode etwa ist kein Web-Ziel, * obwohl der Typ „Zahlung“ in seiner Link-Variante sehr wohl dynamisch sein * kann. Ohne diese Probe entstünde ein gespeicherter Code, der erst beim * Scannen auffällt — also genau dann, wenn er schon gedruckt ist. */ export function assertDynamicPossible( qrType: QrTypeModule, data: Record, ): void { if (!qrType.supportsDynamic) { throw new QrRequestError( `Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`, ); } // Typen mit eigener Landingpage haben kein fremdes Ziel, das scheitern // könnte — die Probe wäre gegenstandslos. if (qrType.hasLandingPage) return; try { qrType.redirectTarget(data, {}); } catch (error) { if (error instanceof QrTypeError) { throw new QrRequestError(error.message); } throw error; } } export type ResolvedQrRequest = { qrType: QrTypeModule; data: Record; /** Der Text, der tatsächlich in den QR-Code kodiert wird. */ content: string; }; /** * Prüft eine Anfrage vollständig und liefert den zu kodierenden Inhalt: * bei statischen Codes die Nutzdaten selbst, bei dynamischen die Kurz-URL. */ export async function resolveQrRequest( request: QrRequest, ): Promise { const qrType = await requireAvailableQrType(request.type); const validated = qrType.validate(request.data); if (!validated.ok) { throw new QrRequestError(validated.message); } if (request.mode === "dynamic") { assertDynamicPossible(qrType, validated.data); const slug = request.slug; if (slug !== undefined && !isValidSlug(slug)) { throw new QrRequestError("Ungültige Kurz-URL."); } // Der Hostname kommt aus der Datenbank, nicht aus der Anfrage: sonst // ließe sich eine beliebige Fremddomain in einen Code kodieren. let hostname: string | undefined; if (request.domainId) { const domain = await prisma.domain.findUnique({ where: { id: request.domainId }, select: { hostname: true }, }); if (!domain) { throw new QrRequestError("Diese Domain gibt es nicht.", 404); } hostname = domain.hostname; } return { qrType, data: validated.data, content: shortUrl(slug ?? PREVIEW_SLUG, hostname), }; } if (!qrType.supportsStatic) { throw new QrRequestError( `Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`, ); } return { qrType, data: validated.data, content: qrType.encode(validated.data), }; } /** Macht aus beliebigem Text einen brauchbaren Dateinamen-Bestandteil. */ function alsDateiname(text: string): string { return text .toLowerCase() .replaceAll("ä", "ae") .replaceAll("ö", "oe") .replaceAll("ü", "ue") .replaceAll("ß", "ss") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60); } /** * Dateiname für den Download. * * Wenn ein Name vergeben wurde, gewinnt er — genau dafür ist er da: damit man * die Datei später wiederfindet. Sonst wie bisher aus dem Inhalt abgeleitet. */ export function downloadFilename( qrType: QrTypeModule, data: unknown, extension: string, label?: string | null, ): string { const ausName = label ? alsDateiname(label) : ""; if (ausName) return `qr-${ausName}.${extension}`; let base = "qr-code"; try { const summary = qrType .summary(data) .toLowerCase(); const gesaeubert = alsDateiname(summary); if (gesaeubert) base = `qr-${gesaeubert}`; } catch { // Zusammenfassung ist optional — im Zweifel bleibt der Standardname. } return `${base}.${extension}`; }