Das Feld „Name (optional)" stand nur im Dynamisch-Block. Bei einem statischen Code gab es damit keine Moeglichkeit, ihm einen Namen zum Wiederfinden zu geben. Jetzt gilt es in beiden Betriebsarten und tut zweierlei: Es bestimmt den Dateinamen beim Herunterladen — aus „Plakat Eingang" wird qr-plakat-eingang.png statt qr-example-com-plakat.png — und es laesst sich damit ein statischer Code in „Meine Codes" ablegen. Ein abgelegter statischer Code ist ein Schnappschuss, kein steuerbares Ziel. Aenderbar ist nur der Name; Inhalt und Gestaltung bleiben, wie sie beim Ablegen waren, denn genau das steckt im gedruckten Bild. Er bekommt keinen Kurz-Link, ist unter /r nicht erreichbar und zaehlt keine Scans — in der Liste steht dort „statisch" und ein Strich. Wer nach dem Ablegen den Inhalt aendert, bekommt das Ablegen erneut angeboten; der vorherige Eintrag bleibt als eigener Schnappschuss. Dafuer traegt QrCode jetzt `mode`, und `slug` darf leer sein. In PostgreSQL schliessen sich mehrere NULL in einem eindeutigen Index nicht aus, die Bedingung „je Domain eindeutig" gilt fuer dynamische Codes also unveraendert. Refs #2
182 lines
5.4 KiB
TypeScript
182 lines
5.4 KiB
TypeScript
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<typeof qrModeSchema>;
|
|
|
|
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<typeof qrRequestSchema>;
|
|
|
|
// 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<string, unknown>,
|
|
): 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<string, unknown>;
|
|
/** 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<ResolvedQrRequest> {
|
|
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}`;
|
|
}
|