Schliesst Issue #1. Der Wunsch betraf den Kurz-Link, nicht das Ziel: Er soll sich von Anfang an bestimmen und spaeter aendern lassen. Beim Anlegen gibt es dafuer ein Feld, im Bearbeiten-Dialog laesst er sich aendern. Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und „/" als Trenner — damit sind Adressen wie kunde-a/sommer moeglich. Umlaute bleiben draussen, weil sie in Adressen unleserlich kodiert werden; Gross- und Kleinschreibung ist beim Aufrufen egal. Beim Aendern bleibt die vorherige Adresse gueltig. Das ist der Kern: Auf gedruckten Codes steht die alte, und genau deren Bestand ist der Sinn eines dynamischen Codes. Frueher vergebene Adressen stehen im Dialog und lassen sich einzeln freigeben — fuer den Tippfehler, mit dem noch nichts gedruckt ist. Solange eine Adresse gehalten wird, kann sie kein anderer Code uebernehmen; sonst liessen sich fremde gedruckte Codes umlenken. Vergeben und Aendern ist standardmaessig Administratoren vorbehalten. Ein Kurz-Link ist eine oeffentliche Adresse, und wer ihn aendert, kann Aushaenge entwerten. Unter Verwaltung -> Einstellungen laesst sich das fuer Bearbeiter freigeben; ohne das Recht erscheint das Feld gar nicht, Codes anlegen und deren Ziel aendern geht weiterhin. Eigene Aktion in der Policy statt Teil von codes:update, weil sie mehr wiegt. Technisch: /r und /s sind Sammelrouten, weil ein Kurz-Link „/" enthalten darf. Die Eindeutigkeit gilt zusaetzlich auf der kleingeschriebenen Form; die Migration bricht ab, falls der Bestand das nicht zulaesst, statt still Daten zu verlieren. Der bestehende exakte Index bleibt — er traegt den haeufigen Fall, den gescannten Code.
124 lines
4.1 KiB
TypeScript
124 lines
4.1 KiB
TypeScript
import type { NextRequest } from "next/server";
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
|
|
import { getQrType } from "@/lib/qr/registry";
|
|
import { isValidSlug } from "@/lib/slug";
|
|
import { findCodeBySlug } from "@/lib/slug-resolve";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
// Ein Kurz-Link darf „/" enthalten (z. B. kunde-a/sommer), deshalb eine
|
|
// Sammelroute: Next liefert die Abschnitte dann als Liste.
|
|
type Context = { params: Promise<{ slug: string[] }> };
|
|
|
|
function notFound(message: string): Response {
|
|
return new Response(
|
|
`<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>QR-Code nicht gefunden</title>
|
|
</head>
|
|
<body style="font-family: system-ui, sans-serif; margin: 0; display: grid; place-items: center; min-height: 100vh; background: #f8fafc; color: #0f172a;">
|
|
<main style="text-align: center; padding: 2rem; max-width: 32rem;">
|
|
<p style="font-size: 3rem; margin: 0;">🔍</p>
|
|
<h1 style="font-size: 1.5rem; margin: 1rem 0 0.5rem;">QR-Code nicht gefunden</h1>
|
|
<p style="color: #475569; line-height: 1.6;">${message}</p>
|
|
</main>
|
|
</body>
|
|
</html>`,
|
|
{ status: 404, headers: { "Content-Type": "text/html; charset=utf-8" } },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Weiterleitung eines dynamischen QR-Codes.
|
|
*
|
|
* Bewusst 302 (temporär): Das Ziel ist jederzeit änderbar, ein permanenter
|
|
* Redirect würde in Browsern und Zwischenspeichern hängen bleiben.
|
|
*
|
|
* Der Slug ist nur je Domain eindeutig, deshalb wird zuerst die Domain aus
|
|
* dem `Host`-Header bestimmt. Ein unbekannter Hostname endet hier — sonst
|
|
* ließe sich über einen gefälschten Header ein fremder Code ansprechen.
|
|
*/
|
|
export async function GET(
|
|
request: NextRequest,
|
|
context: Context,
|
|
): Promise<Response> {
|
|
const abschnitte = (await context.params).slug;
|
|
const slug = (abschnitte ?? []).join("/");
|
|
|
|
if (!isValidSlug(slug)) {
|
|
return notFound("Diese Kurz-Adresse ist ungültig.");
|
|
}
|
|
|
|
const matched = await resolveRequestDomain(request.headers);
|
|
|
|
// Aufruf über eine nicht registrierte Adresse — etwa direkt über die IP im
|
|
// LAN. Dann gilt die Standard-Domain aus BASE_URL.
|
|
const domain = matched ?? {
|
|
...(await ensureDefaultDomain()),
|
|
isActive: true,
|
|
};
|
|
|
|
if (!domain.isActive) {
|
|
return notFound(
|
|
"Diese Adresse ist zurzeit abgeschaltet. Bitte wende dich an denjenigen, der den Code erstellt hat.",
|
|
);
|
|
}
|
|
|
|
const gefunden = await findCodeBySlug(domain.id, slug);
|
|
const code = gefunden?.code;
|
|
|
|
if (!code) {
|
|
return notFound(
|
|
"Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an denjenigen, der ihn erstellt hat.",
|
|
);
|
|
}
|
|
|
|
const qrType = getQrType(code.type);
|
|
if (!qrType) {
|
|
return notFound("Der Typ dieses Codes wird nicht mehr unterstützt.");
|
|
}
|
|
|
|
let target: string;
|
|
if (qrType.hasLandingPage) {
|
|
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
|
|
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
|
|
// wenn das eine nicht registrierte ist, etwa eine IP im LAN).
|
|
target = `/s/${code.slug}`;
|
|
} else {
|
|
try {
|
|
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
|
|
// wohin die Weiterleitung führt.
|
|
target = qrType.redirectTarget(code.data, {
|
|
userAgent: request.headers.get("user-agent") ?? undefined,
|
|
});
|
|
} catch {
|
|
return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
|
|
}
|
|
}
|
|
|
|
// Der Zähler darf die Weiterleitung nicht ausbremsen oder blockieren.
|
|
try {
|
|
await prisma.qrCode.update({
|
|
where: { id: code.id },
|
|
data: { scans: { increment: 1 }, lastScanAt: new Date() },
|
|
});
|
|
} catch (error) {
|
|
console.error("Scan-Zähler konnte nicht erhöht werden:", error);
|
|
}
|
|
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: {
|
|
Location: target,
|
|
// Ohne dies würden Browser die Weiterleitung zwischenspeichern und
|
|
// spätere Zieländerungen sowie der Scan-Zähler blieben wirkungslos.
|
|
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
},
|
|
});
|
|
}
|