From 46e7eb0b59ae48e237d4bf752f965a34d66be9ab Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 17 Aug 2026 10:41:57 +0200 Subject: [PATCH] Sprechende Kurz-Links: selbst vergeben, aendern, frueher gueltig halten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 + docs/funktionen.md | 42 +++++- docs/roadmap.md | 9 ++ .../migration.sql | 56 +++++++ .../migration.sql | 12 ++ prisma/schema.prisma | 39 +++++ src/app/(app)/erstellen/[type]/page.tsx | 14 +- src/app/(app)/meine-codes/page.tsx | 12 +- .../(app)/verwaltung/einstellungen/page.tsx | 39 +++++ src/app/(app)/verwaltung/page.tsx | 15 +- src/app/api/admin/settings/route.ts | 40 +++++ src/app/api/codes/[id]/route.ts | 95 +++++++++++- src/app/api/codes/route.ts | 34 ++++- src/app/r/{[slug] => [...slug]}/route.ts | 15 +- src/app/s/{[slug] => [...slug]}/page.tsx | 12 +- src/components/codes-list.tsx | 139 ++++++++++++++++-- src/components/qr-generator.tsx | 45 +++++- src/components/settings-admin.tsx | 84 +++++++++++ src/lib/auth/policy.ts | 28 +++- src/lib/codes.ts | 2 + src/lib/dto.ts | 2 + src/lib/settings.ts | 45 ++++++ src/lib/slug-resolve.ts | 91 ++++++++++++ src/lib/slug.ts | 94 +++++++++++- 24 files changed, 917 insertions(+), 49 deletions(-) create mode 100644 prisma/migrations/20260817090000_eigene_kurzlinks/migration.sql create mode 100644 prisma/migrations/20260817120000_einstellungen/migration.sql create mode 100644 src/app/(app)/verwaltung/einstellungen/page.tsx create mode 100644 src/app/api/admin/settings/route.ts rename src/app/r/{[slug] => [...slug]}/route.ts (89%) rename src/app/s/{[slug] => [...slug]}/page.tsx (89%) create mode 100644 src/components/settings-admin.tsx create mode 100644 src/lib/settings.ts create mode 100644 src/lib/slug-resolve.ts diff --git a/README.md b/README.md index d068ed1..849b10a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden. Admin-Bereich einstellbar. - **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine eigene Wahl. +- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen, + nachträglich änderbar — die vorherige Adresse bleibt gültig. - **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start von selbst; alles Nötige steht in einer `.env`. diff --git a/docs/funktionen.md b/docs/funktionen.md index 043810a..910e64d 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -104,10 +104,41 @@ Browser landet. | Braucht diesen Server | nie | dauerhaft | | In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler | -Der **Slug** ist acht Zeichen lang aus einem Alphabet ohne verwechselbare -Zeichen (kein `0`, `O`, `1`, `l`, `I`). Er ist **je Domain** eindeutig, nicht -global — derselbe Slug darf auf zwei Domains liegen und dort auf -Verschiedenes zeigen. +### Der Kurz-Link + +Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne +verwechselbare Zeichen (kein `0`, `O`, `1`, `l`, `I`) — Kurz-URLs werden +gelegentlich abgetippt. + +**Selbst bestimmen** lässt er sich beim Anlegen und später ändern: + +| | | +| --- | --- | +| Erlaubt | Kleinbuchstaben, Ziffern, Bindestriche, `/` als Trenner | +| Beispiele | `sommeraktion`, `tisch-14`, `kunde-a/flyer` | +| Nicht erlaubt | Umlaute, Leerzeichen, Unterstriche, Bindestrich am Rand eines Abschnitts | +| Länge | bis 100 Zeichen, höchstens 3 Abschnitte à 48 Zeichen | + +Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: `SommerAktion` +findet `sommeraktion`. Gespeichert wird kleingeschrieben, und zwei Kurz-Links +dürfen sich nicht nur in der Schreibweise unterscheiden. + +**Beim Ändern bleibt die vorherige Adresse gültig.** Das ist der Sinn der +Sache: Auf gedruckten Codes steht die alte, und die soll weiter funktionieren. +Frühere Adressen stehen im Bearbeiten-Dialog und lassen sich einzeln +**freigeben** — für den Fall eines Tippfehlers, mit dem noch nichts gedruckt +ist. Eine freigegebene Adresse ist sofort tot und danach wieder vergebbar. +Solange sie gehalten wird, kann sie kein anderer Code übernehmen; sonst +ließen sich fremde gedruckte Codes umlenken. + +**Wer darf das?** Standardmäßig nur Administratoren — ein Kurz-Link ist eine +öffentliche Adresse, und wer ihn ändert, kann Aushänge entwerten. Unter +Verwaltung → Einstellungen lässt sich das für Bearbeiter freigeben. Ohne das +Recht erscheint das Feld gar nicht; Codes anlegen und deren Ziel ändern geht +weiterhin. + +Der Kurz-Link ist **je Domain** eindeutig, nicht global — derselbe darf auf +zwei Domains liegen und dort auf Verschiedenes zeigen. Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben — @@ -415,6 +446,7 @@ auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads. | Bereich | Was dort geht | | --- | --- | +| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links | | **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe | | **QR-Typen** | einzelne Typen ausblenden | | **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben | @@ -466,7 +498,7 @@ Alles, was die Oberfläche tut, geht auch über HTTP: | `POST /api/feedback` | Rückmeldung abgeben | | `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern | | `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter | -| `/api/admin/types` · `users` · `groups` · `domains` · `branding` | Verwaltung | +| `/api/admin/types` · `users` · `groups` · `domains` · `branding` · `settings` | Verwaltung | | `GET /r/{slug}` | Weiterleitung eines dynamischen Codes | | `GET /s/{slug}` | öffentliche Landingpage | diff --git a/docs/roadmap.md b/docs/roadmap.md index 8fbb738..1bf8113 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -74,6 +74,15 @@ Folgt dem Betriebssystem, mit Umschalter für Hell, Dunkel und System. Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl. +### Sprechende Kurz-Links (17.08.2026) + +Kurz-Links selbst vergeben und nachträglich ändern, mit `/` als Trenner. Die +vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — sonst würde +jedes Umbenennen gedruckte Codes entwerten. Standardmäßig Administratoren +vorbehalten, per Einstellung für Bearbeiter freigebbar. + +Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1). + ### Dokumentation (15.08.2026) Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht. diff --git a/prisma/migrations/20260817090000_eigene_kurzlinks/migration.sql b/prisma/migrations/20260817090000_eigene_kurzlinks/migration.sql new file mode 100644 index 0000000..62bc043 --- /dev/null +++ b/prisma/migrations/20260817090000_eigene_kurzlinks/migration.sql @@ -0,0 +1,56 @@ +-- Selbst gewählte Kurz-Links, umbenennbar, mit weiterhin gültigen Vorgängern. + +-- Frühere Kurz-Links eines Codes. Ohne sie würde jedes Umbenennen die bereits +-- gedruckten Codes entwerten. +CREATE TABLE "QrCodeAlias" ( + "id" TEXT NOT NULL, + "domainId" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "qrCodeId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "QrCodeAlias_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "QrCodeAlias_domainId_slug_key" ON "QrCodeAlias"("domainId", "slug"); +CREATE INDEX "QrCodeAlias_qrCodeId_idx" ON "QrCodeAlias"("qrCodeId"); + +ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_domainId_fkey" + FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_qrCodeId_fkey" + FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Gross- und Kleinschreibung soll beim Aufrufen keine Rolle spielen. Damit +-- „Sommer" und „sommer" nicht als zwei verschiedene Kurz-Links vergeben werden +-- koennen, gilt die Eindeutigkeit ab jetzt auf der kleingeschriebenen Form. +-- +-- Zuerst pruefen, ob der bestehende Bestand das ueberhaupt zulaesst. Bei +-- erzeugten Kurz-Links aus acht Zeichen ist eine solche Kollision praktisch +-- ausgeschlossen; ein stiller Datenverlust waere aber unentschuldbar, deshalb +-- bricht die Migration in dem Fall lieber ab. +DO $$ +DECLARE kollisionen INT; +BEGIN + SELECT COUNT(*) INTO kollisionen FROM ( + SELECT "domainId", LOWER("slug") + FROM "QrCode" + GROUP BY "domainId", LOWER("slug") + HAVING COUNT(*) > 1 + ) AS doppelt; + + IF kollisionen > 0 THEN + RAISE EXCEPTION + 'Es gibt % Kurz-Links, die sich nur in der Gross-/Kleinschreibung unterscheiden. Bitte einen davon umbenennen und die Migration erneut starten.', + kollisionen; + END IF; +END $$; + +-- Der bestehende Index bleibt: Er traegt die exakten Nachschlagevorgaenge, und +-- das ist der haeufige Fall — ein gescannter Code liefert die Adresse genau so, +-- wie sie gespeichert ist. Der zweite Index kommt dazu und sorgt dafuer, dass +-- sich zwei Kurz-Links nicht nur in der Gross-/Kleinschreibung unterscheiden +-- koennen. +CREATE UNIQUE INDEX "QrCode_domainId_lower_slug_key" + ON "QrCode"("domainId", LOWER("slug")); +CREATE UNIQUE INDEX "QrCodeAlias_domainId_lower_slug_key" + ON "QrCodeAlias"("domainId", LOWER("slug")); diff --git a/prisma/migrations/20260817120000_einstellungen/migration.sql b/prisma/migrations/20260817120000_einstellungen/migration.sql new file mode 100644 index 0000000..9216332 --- /dev/null +++ b/prisma/migrations/20260817120000_einstellungen/migration.sql @@ -0,0 +1,12 @@ +-- Verhalten der Installation, das sich nicht aus dem Code ergibt. + +CREATE TABLE "InstanceSetting" ( + "id" TEXT NOT NULL DEFAULT 'instance', + "editorsMaySetSlug" BOOLEAN NOT NULL DEFAULT false, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "InstanceSetting_pkey" PRIMARY KEY ("id") +); + +-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur lesen muss. +INSERT INTO "InstanceSetting" ("id", "updatedAt") VALUES ('instance', CURRENT_TIMESTAMP); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 58ae0f5..1bda016 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -122,6 +122,7 @@ model Domain { grants DomainGrant[] codes QrCode[] + aliases QrCodeAlias[] } /// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen @@ -194,6 +195,8 @@ model QrCode { uploads Upload[] /// Rückmeldungen, die über die Landingpage eingegangen sind. feedback Feedback[] + /// Frühere Kurz-Links, die weiterhin hierher führen. + aliases QrCodeAlias[] @@unique([domainId, slug]) @@index([createdAt]) @@ -237,6 +240,42 @@ model Upload { @@index([purpose]) } +/// Verhalten der Installation, das sich nicht aus dem Code ergibt. +/// +/// Genau eine Zeile mit fester Id — wie `Branding`, nur für Verhalten statt +/// Aussehen. Ein eigenes Modell statt Schlüssel-Wert-Paaren, damit die Felder +/// ihren Typ behalten. +model InstanceSetting { + id String @id @default("instance") + /// Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern? + /// + /// Aus, weil ein Kurz-Link eine öffentliche Adresse ist: Wer ihn ändert, + /// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die + /// jemand anderes gebraucht hätte. Administratoren können das freigeben. + editorsMaySetSlug Boolean @default(false) + updatedAt DateTime @updatedAt +} + +/// Ein früherer Kurz-Link, der weiterhin auf seinen Code führt. +/// +/// Ohne das würde jedes Umbenennen die bereits gedruckten Codes entwerten — +/// und genau deren Unveränderlichkeit ist der Sinn eines dynamischen Codes. +/// Freigeben lässt sich ein solcher Eintrag von Hand, etwa nach einem +/// Tippfehler, den noch niemand gedruckt hat. +model QrCodeAlias { + id String @id @default(cuid()) + /// Mitgeführt, damit die Eindeutigkeit je Domain gilt — wie beim Code selbst. + domainId String + domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade) + slug String + qrCodeId String + qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([domainId, slug]) + @@index([qrCodeId]) +} + /// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe. /// /// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner diff --git a/src/app/(app)/erstellen/[type]/page.tsx b/src/app/(app)/erstellen/[type]/page.tsx index ef614a8..41df18a 100644 --- a/src/app/(app)/erstellen/[type]/page.tsx +++ b/src/app/(app)/erstellen/[type]/page.tsx @@ -4,6 +4,9 @@ import { notFound } from "next/navigation"; import { Icon } from "@/components/icons"; import { QrGenerator } from "@/components/qr-generator"; +import { resolveTargetGroup } from "@/lib/auth/groups"; +import { can } from "@/lib/auth/policy"; +import { getSettings } from "@/lib/settings"; import { requirePageActor } from "@/lib/auth/guard"; import { getQrTypeState } from "@/lib/qr/availability"; import { getQrType } from "@/lib/qr/registry"; @@ -47,7 +50,7 @@ function Hinweis({ } export default async function CreatePage({ params }: Props) { - await requirePageActor(); + const actor = await requirePageActor(); const { type } = await params; const state = await getQrTypeState(type); @@ -75,5 +78,12 @@ export default async function CreatePage({ params }: Props) { ); } - return ; + // Ob der Kurz-Link selbst vergeben werden darf, haengt an Rolle und + // Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin + // noch einmal. + const { editorsMaySetSlug } = await getSettings(); + const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined); + const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug }); + + return ; } diff --git a/src/app/(app)/meine-codes/page.tsx b/src/app/(app)/meine-codes/page.tsx index 9880e3c..26bcf7d 100644 --- a/src/app/(app)/meine-codes/page.tsx +++ b/src/app/(app)/meine-codes/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { CodesList } from "@/components/codes-list"; +import { can } from "@/lib/auth/policy"; +import { getSettings } from "@/lib/settings"; import { requirePageActor } from "@/lib/auth/guard"; export const metadata: Metadata = { title: "Meine Codes" }; @@ -8,10 +10,16 @@ export const metadata: Metadata = { title: "Meine Codes" }; export const dynamic = "force-dynamic"; export default async function MyCodesPage() { - await requirePageActor(); + const actor = await requirePageActor(); // Breiter als die übrigen Seiten: Die Liste soll viele Projekte // nebeneinander zeigen können. + // Der Kurz-Link darf je Gruppe verschieden bearbeitbar sein; hier zaehlt, + // ob es irgendwo erlaubt ist. Die Route prueft es beim Speichern erneut, + // dann mit der Gruppe des betroffenen Codes. + const { editorsMaySetSlug } = await getSettings(); + const maySetSlug = can(actor, "codes:slug", { editorsMaySetSlug }); + return (
@@ -25,7 +33,7 @@ export default async function MyCodesPage() {

- +
); } diff --git a/src/app/(app)/verwaltung/einstellungen/page.tsx b/src/app/(app)/verwaltung/einstellungen/page.tsx new file mode 100644 index 0000000..a3446f0 --- /dev/null +++ b/src/app/(app)/verwaltung/einstellungen/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { Icon } from "@/components/icons"; +import { SettingsAdmin } from "@/components/settings-admin"; +import { requirePagePermission } from "@/lib/auth/guard"; +import { getSettings } from "@/lib/settings"; + +export const metadata: Metadata = { title: "Einstellungen" }; + +export const dynamic = "force-dynamic"; + +export default async function SettingsPage() { + await requirePagePermission("instance:manage"); + const settings = await getSettings(); + + return ( +
+ + + Zur Verwaltung + + +
+

+ Einstellungen +

+

+ Was Bearbeiter dürfen und was Administratoren vorbehalten bleibt. +

+
+ + +
+ ); +} diff --git a/src/app/(app)/verwaltung/page.tsx b/src/app/(app)/verwaltung/page.tsx index 5c0dc70..f34a0eb 100644 --- a/src/app/(app)/verwaltung/page.tsx +++ b/src/app/(app)/verwaltung/page.tsx @@ -6,6 +6,7 @@ import { Notice } from "@/components/ui"; import { isAuthEnabled } from "@/lib/auth/actor"; import { requirePagePermission } from "@/lib/auth/guard"; import { getBranding } from "@/lib/branding"; +import { getSettings } from "@/lib/settings"; import { prisma } from "@/lib/db"; export const metadata: Metadata = { title: "Verwaltung" }; @@ -46,13 +47,14 @@ function Tile({ export default async function AdminPage() { await requirePagePermission("instance:manage"); - const [userCount, groupCount, codeCount, domainCount, branding] = + const [userCount, groupCount, codeCount, domainCount, branding, settings] = await Promise.all([ prisma.user.count(), prisma.group.count(), prisma.qrCode.count(), prisma.domain.count(), getBranding(), + getSettings(), ]); const eigenes = [ @@ -120,6 +122,17 @@ export default async function AdminPage() { description="Festlegen, welche Typen auf der Startseite erscheinen." hint="15 Typen insgesamt" /> + { + try { + requirePermission(await getActor(), "instance:manage"); + return Response.json({ settings: await getSettings() }); + } catch (error) { + return errorResponse(error); + } +} + +export async function PATCH(request: NextRequest): Promise { + try { + requirePermission(await getActor(), "instance:manage"); + + const parsed = updateSchema.safeParse(await request.json()); + if (!parsed.success) { + return jsonError(firstZodMessage(parsed.error)); + } + if (Object.keys(parsed.data).length === 0) { + return jsonError("Es wurden keine Änderungen übermittelt."); + } + + return Response.json({ settings: await saveSettings(parsed.data) }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/src/app/api/codes/[id]/route.ts b/src/app/api/codes/[id]/route.ts index 3b1653d..a8d04a2 100644 --- a/src/app/api/codes/[id]/route.ts +++ b/src/app/api/codes/[id]/route.ts @@ -10,6 +10,9 @@ import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { requireImplementedQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; import { assertDynamicPossible } from "@/lib/qr/request"; +import { describeSlugProblem, normalizeSlug } from "@/lib/slug"; +import { assertSlugAvailable } from "@/lib/slug-resolve"; +import { getSettings } from "@/lib/settings"; import { attachUploads } from "@/lib/uploads"; import { QrRequestError } from "@/lib/qr/type-module"; @@ -21,6 +24,7 @@ const WITH_RELATIONS = { group: { select: { name: true } }, domain: { select: { hostname: true } }, createdBy: { select: { name: true, email: true } }, + aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } }, } as const; const updateSchema = z.object({ @@ -32,6 +36,17 @@ const updateSchema = z.object({ .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .nullable() .optional(), + /** Neuer Kurz-Link. Der bisherige bleibt als frühere Adresse gültig. */ + slug: z.string().trim().max(120).optional(), + /** + * Welche früheren Adressen weiterhin gelten sollen. + * + * Bewusst als vollständige Liste und nicht als „lösche diese eine": So + * beschreibt die Anfrage den gewünschten Zustand, und die Oberfläche muss + * sich nicht merken, was sie schon geschickt hat. Was fehlt, wird + * freigegeben — und ist damit sofort tot. + */ + aliases: z.array(z.string().trim().max(120)).max(50).optional(), }); async function loadCode(id: string) { @@ -109,14 +124,84 @@ export async function PATCH( Object.assign(update, parsed.data.design); } - if (Object.keys(update).length === 0) { + /* ------------------------------------------------------- Kurz-Link */ + + const neuerSlug = parsed.data.slug?.trim(); + const umbenannt = + neuerSlug !== undefined && + normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug); + + /** + * Welche früheren Adressen gelten sollen. Fehlt das Feld, bleibt alles wie + * es ist; steht es da, beschreibt es den gewünschten Zustand. + */ + const behalten = + parsed.data.aliases === undefined + ? null + : new Set(parsed.data.aliases.map(normalizeSlug)); + + const vorhandeneAliase = await prisma.qrCodeAlias.findMany({ + where: { qrCodeId: existing.id }, + select: { id: true, slug: true }, + }); + + const freigeben = behalten + ? vorhandeneAliase.filter((a) => !behalten.has(normalizeSlug(a.slug))) + : []; + + // Erst prüfen, wenn wirklich etwas am Kurz-Link geändert wird. Die + // Oberfläche schickt die Liste der früheren Adressen bei jeder Änderung + // mit — unverändert soll das kein zusätzliches Recht verlangen. + if (umbenannt || freigeben.length > 0) { + const { editorsMaySetSlug } = await getSettings(); + requirePermission(actor, "codes:slug", { + groupId: existing.groupId, + editorsMaySetSlug, + }); + } + + if (umbenannt) { + const problem = describeSlugProblem(neuerSlug); + if (problem) return jsonError(problem); + await assertSlugAvailable(existing.domainId, neuerSlug, existing.id); + update.slug = normalizeSlug(neuerSlug); + } + + if ( + Object.keys(update).length === 0 && + !umbenannt && + freigeben.length === 0 + ) { return jsonError("Es wurden keine Änderungen übermittelt."); } - const updated = await prisma.qrCode.update({ - where: { id }, - data: update, - include: WITH_RELATIONS, + // Umbenennen, alte Adresse sichern und Freigaben in einem Zug — sonst + // gäbe es einen Moment, in dem der Code unter keiner Adresse erreichbar + // ist, oder eine freigegebene Adresse bliebe hängen. + const updated = await prisma.$transaction(async (tx) => { + if (freigeben.length > 0) { + await tx.qrCodeAlias.deleteMany({ + where: { id: { in: freigeben.map((a) => a.id) } }, + }); + } + + if (umbenannt) { + // Der bisherige Kurz-Link bleibt gültig — daran hängen gedruckte + // Codes. Freigeben lässt er sich danach einzeln. + await tx.qrCodeAlias.create({ + data: { + domainId: existing.domainId, + slug: existing.slug, + qrCodeId: existing.id, + }, + }); + } + + return tx.qrCode.update({ + where: { id }, + data: update, + include: WITH_RELATIONS, + }); }); // Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts index 5feb0e6..8aef2e4 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -12,7 +12,9 @@ import { requireAvailableQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; import { assertDynamicPossible } from "@/lib/qr/request"; import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module"; -import { generateSlug } from "@/lib/slug"; +import { describeSlugProblem, generateSlug, normalizeSlug } from "@/lib/slug"; +import { assertSlugAvailable } from "@/lib/slug-resolve"; +import { getSettings } from "@/lib/settings"; import { attachUploads } from "@/lib/uploads"; export const dynamic = "force-dynamic"; @@ -26,6 +28,11 @@ const createSchema = z.object({ .trim() .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .optional(), + /** + * Selbst gewählter Kurz-Link. Ohne Angabe wird einer erzeugt. + * Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge. + */ + slug: z.string().trim().max(120).optional(), /** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */ groupId: z.string().trim().min(1).optional(), /** Unter welcher Domain die Kurz-URL entsteht. */ @@ -57,6 +64,7 @@ export async function GET(): Promise { group: { select: { name: true } }, domain: { select: { hostname: true } }, createdBy: { select: { name: true, email: true } }, + aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } }, }, }); @@ -96,12 +104,25 @@ export async function POST(request: NextRequest): Promise { // Erst hier, nach der Validierung: die Probe braucht die geprüften Daten. assertDynamicPossible(qrType, validated.data); + // Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht — + // sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte. + const gewuenscht = parsed.data.slug?.trim(); + if (gewuenscht) { + // Einen Kurz-Link selbst zu vergeben wiegt mehr als einen Code anzulegen. + const { editorsMaySetSlug } = await getSettings(); + requirePermission(actor, "codes:slug", { groupId, editorsMaySetSlug }); + + const problem = describeSlugProblem(gewuenscht); + if (problem) return jsonError(problem); + await assertSlugAvailable(domainId, gewuenscht); + } + // Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich. for (let attempt = 0; attempt < 5; attempt += 1) { try { const created = await prisma.qrCode.create({ data: { - slug: generateSlug(), + slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(), type: qrType.id, label: label && label.length > 0 ? label : null, data: toJsonInput(validated.data), @@ -114,6 +135,7 @@ export async function POST(request: NextRequest): Promise { group: { select: { name: true } }, domain: { select: { hostname: true } }, createdBy: { select: { name: true, email: true } }, + aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } }, }, }); // Erst jetzt gehören die hochgeladenen Dateien zu diesem Code — @@ -122,6 +144,14 @@ export async function POST(request: NextRequest): Promise { return Response.json({ code: toQrCodeDto(created) }, { status: 201 }); } catch (error) { + // Bei einem selbst gewählten Kurz-Link hilft kein zweiter Versuch: Er + // käme immer wieder auf dieselbe Adresse. + if (gewuenscht && isUniqueViolation(error)) { + return jsonError( + `Der Kurz-Link „${normalizeSlug(gewuenscht)}" ist auf dieser Domain schon vergeben.`, + 409, + ); + } if (!isUniqueViolation(error)) throw error; } } diff --git a/src/app/r/[slug]/route.ts b/src/app/r/[...slug]/route.ts similarity index 89% rename from src/app/r/[slug]/route.ts rename to src/app/r/[...slug]/route.ts index b5283bc..dc66aa2 100644 --- a/src/app/r/[slug]/route.ts +++ b/src/app/r/[...slug]/route.ts @@ -4,10 +4,13 @@ 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"; -type Context = { params: Promise<{ slug: string }> }; +// 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( @@ -44,7 +47,8 @@ export async function GET( request: NextRequest, context: Context, ): Promise { - const { slug } = await context.params; + const abschnitte = (await context.params).slug; + const slug = (abschnitte ?? []).join("/"); if (!isValidSlug(slug)) { return notFound("Diese Kurz-Adresse ist ungültig."); @@ -65,9 +69,8 @@ export async function GET( ); } - const code = await prisma.qrCode.findUnique({ - where: { domainId_slug: { domainId: domain.id, slug } }, - }); + const gefunden = await findCodeBySlug(domain.id, slug); + const code = gefunden?.code; if (!code) { return notFound( @@ -85,7 +88,7 @@ export async function GET( // 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/${slug}`; + target = `/s/${code.slug}`; } else { try { // Der User-Agent geht mit: Typen wie der App-Link entscheiden daran, diff --git a/src/app/s/[slug]/page.tsx b/src/app/s/[...slug]/page.tsx similarity index 89% rename from src/app/s/[slug]/page.tsx rename to src/app/s/[...slug]/page.tsx index 69fabaa..a6d731b 100644 --- a/src/app/s/[slug]/page.tsx +++ b/src/app/s/[...slug]/page.tsx @@ -3,14 +3,15 @@ import { headers } from "next/headers"; import { qrLandings } from "@/components/landing"; import { LandingNotice } from "@/components/landing/shell"; -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"; -type PageProps = { params: Promise<{ slug: string }> }; +// Sammelroute, weil ein Kurz-Link „/" enthalten darf. +type PageProps = { params: Promise<{ slug: string[] }> }; /** * Öffentliche Landingpage eines dynamischen Codes. @@ -21,16 +22,15 @@ type PageProps = { params: Promise<{ slug: string }> }; * Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`. * Sonst zählte jedes Neuladen dieser Seite als weiterer Scan. */ -async function loadCode(slug: string) { +async function loadCode(abschnitte: string[] | undefined) { + const slug = (abschnitte ?? []).join("/"); if (!isValidSlug(slug)) return null; const matched = await resolveRequestDomain(await headers()); const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true }; if (!domain.isActive) return null; - return prisma.qrCode.findUnique({ - where: { domainId_slug: { domainId: domain.id, slug } }, - }); + return (await findCodeBySlug(domain.id, slug))?.code ?? null; } export async function generateMetadata({ diff --git a/src/components/codes-list.tsx b/src/components/codes-list.tsx index aa195cf..a9aced1 100644 --- a/src/components/codes-list.tsx +++ b/src/components/codes-list.tsx @@ -19,6 +19,7 @@ import { import { ApiError, apiJson } from "@/lib/api-client"; import type { QrCodeDto } from "@/lib/dto"; import { qrTypes } from "@/lib/qr/registry"; +import { describeSlugProblem } from "@/lib/slug"; import type { QrFormData } from "@/lib/qr/type-module"; const dateFormat = new Intl.DateTimeFormat("de-DE", { @@ -65,11 +66,19 @@ function displayName(code: QrCodeDto): string { return code.label?.trim() || code.summary; } -export function CodesList() { +export function CodesList({ + /** Darf der Kurz-Link geändert werden? Sonst erscheinen die Felder nicht. */ + maySetSlug = true, +}: { + maySetSlug?: boolean; +}) { const [codes, setCodes] = useState(null); const [loadError, setLoadError] = useState(null); const [editing, setEditing] = useState(null); + const [editSlug, setEditSlug] = useState(""); + /** Welche früheren Adressen gelten sollen — was hier fehlt, wird freigegeben. */ + const [editAliases, setEditAliases] = useState([]); const [editData, setEditData] = useState({}); const [editLabel, setEditLabel] = useState(""); const [editError, setEditError] = useState(null); @@ -124,9 +133,19 @@ export function CodesList() { setEditing(code); setEditData(code.data); setEditLabel(code.label ?? ""); + setEditSlug(code.slug); + setEditAliases(code.aliases); setEditError(null); }; + const slugProblem = + editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null; + const slugGeaendert = + editing !== null && editSlug.trim().toLowerCase() !== editing.slug.toLowerCase(); + const freigegeben = editing + ? editing.aliases.filter((alias) => !editAliases.includes(alias)) + : []; + const EditForm = editing ? qrForms[editing.type] : undefined; const editValidation = useMemo( () => (editing ? qrTypes[editing.type].validate(editData) : null), @@ -139,12 +158,23 @@ export function CodesList() { setEditError(editValidation.message); return; } + if (slugProblem) { + setEditError(slugProblem); + return; + } setSaving(true); setEditError(null); try { await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, { method: "PATCH", - json: { data: editValidation.data, label: editLabel.trim() || null }, + json: { + data: editValidation.data, + label: editLabel.trim() || null, + // Nur mitschicken, wenn es überhaupt bearbeitet werden durfte. + ...(maySetSlug + ? { slug: editSlug.trim(), aliases: editAliases } + : {}), + }, }); setEditing(null); await load(); @@ -539,8 +569,8 @@ export function CodesList() { setEditing(null)} footer={ <> @@ -742,12 +772,8 @@ export function CodesList() { {editing ? (

- Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin - auf{" "} - - {editing.shortUrl} - - . + Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt + gültig, solange der Kurz-Link derselbe ist.

{EditForm ? ( @@ -766,6 +792,95 @@ export function CodesList() { )} + {maySetSlug ? ( + <> + + { + setEditSlug(event.target.value); + setEditError(null); + }} + /> + + + {slugProblem ? ( + {slugProblem} + ) : slugGeaendert ? ( + + Der bisherige Kurz-Link{" "} + {editing.slug}{" "} + bleibt gültig — schon gedruckte Codes funktionieren weiter. Unten + lässt er sich später freigeben. + + ) : null} + + {editing.aliases.length > 0 ? ( +
+ + Frühere Kurz-Links + +

+ Führen weiterhin auf diesen Code. Freigeben nur, wenn damit + nichts gedruckt ist — die Adresse ist danach sofort tot. +

+
    + {editing.aliases.map((alias) => { + const behalten = editAliases.includes(alias); + return ( +
  • + + /r/{alias} + + +
  • + ); + })} +
+ {freigegeben.length > 0 ? ( + + {freigegeben.length === 1 + ? "Eine frühere Adresse wird beim Speichern freigegeben und funktioniert danach nicht mehr." + : `${freigegeben.length} frühere Adressen werden beim Speichern freigegeben und funktionieren danach nicht mehr.`} + + ) : null} +
+ ) : null} + + ) : null} + (null); const [saving, setSaving] = useState(false); @@ -164,6 +174,11 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) { /* ----------------------------------------------- Dynamischen Code speichern */ + // Sofortige Rückmeldung, damit man nicht erst beim Speichern erfährt, dass + // ein Zeichen nicht erlaubt ist. + const slugProblem = + wunschSlug.trim().length > 0 ? describeSlugProblem(wunschSlug) : null; + const hasUnsavedChanges = saved !== null && (!sameData(saved.data, validation.ok ? validation.data : data) || @@ -171,7 +186,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) { (saved.label ?? "") !== label.trim()); const handleSave = useCallback(async () => { - if (!validation.ok || !designValid) return; + if (!validation.ok || !designValid || slugProblem) return; setSaving(true); setSaveError(null); try { @@ -180,6 +195,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) { data: validation.data, design, label: label.trim() || undefined, + slug: wunschSlug.trim() || undefined, }; const result = saved ? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, { @@ -200,7 +216,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) { } finally { setSaving(false); } - }, [validation, designValid, typeId, design, label, saved]); + }, [validation, designValid, slugProblem, typeId, design, label, wunschSlug, saved]); const handleCopy = useCallback(async () => { if (!saved) return; @@ -347,6 +363,27 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) { /> + {/* Nur beim Anlegen: Danach steht der Kurz-Link im + Bearbeiten-Dialog, wo auch die früheren Adressen liegen. */} + {saved || !maySetSlug ? null : ( + + setWunschSlug(event.target.value)} + /> + + )} + {saved ? (
@@ -408,7 +445,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) { diff --git a/src/components/settings-admin.tsx b/src/components/settings-admin.tsx new file mode 100644 index 0000000..34fb2d1 --- /dev/null +++ b/src/components/settings-admin.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; + +import { Card, CardTitle, Checkbox, Notice } from "@/components/ui"; + +export type SettingsDto = { + editorsMaySetSlug: boolean; +}; + +export function SettingsAdmin({ initial }: { initial: SettingsDto }) { + const [wert, setWert] = useState(initial); + const [fehler, setFehler] = useState(null); + const [hinweis, setHinweis] = useState(null); + const [laeuft, setLaeuft] = useState(false); + + // Bewusst ohne Speichern-Knopf: Ein einzelner Schalter, der erst nach einem + // zweiten Klick wirkt, ist eine Stolperfalle. + const umschalten = async (aktiv: boolean) => { + setLaeuft(true); + setFehler(null); + setHinweis(null); + const vorher = wert; + setWert({ ...wert, editorsMaySetSlug: aktiv }); + try { + const antwort = await fetch("/api/admin/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ editorsMaySetSlug: aktiv }), + }); + const rumpf = await antwort.json().catch(() => null); + if (!antwort.ok) { + throw new Error(rumpf?.error ?? "Speichern nicht möglich."); + } + setWert(rumpf.settings); + setHinweis( + aktiv + ? "Bearbeiter dürfen Kurz-Links jetzt selbst vergeben und ändern." + : "Kurz-Links vergeben und ändern dürfen jetzt nur noch Administratoren.", + ); + } catch (error) { + setWert(vorher); + setFehler(error instanceof Error ? error.message : String(error)); + } finally { + setLaeuft(false); + } + }; + + return ( +
+ {fehler ? {fehler} : null} + {hinweis ? {hinweis} : null} + + + + Kurz-Links + + + void umschalten(event.target.checked)} + /> + +
+

+ Ein Kurz-Link ist eine öffentliche Adresse und steht auf gedruckten + Codes. Wer ihn ändert, kann damit Aushänge und Flyer entwerten — + die vorherige Adresse bleibt zwar gültig, lässt sich aber auch + wieder freigeben. +

+

+ Deshalb ist das standardmäßig Administratoren vorbehalten. In einem + kleinen Team, in dem alle wissen, was ein Kurz-Link bedeutet, ist + die Freigabe unbedenklich. +

+
+
+
+ ); +} diff --git a/src/lib/auth/policy.ts b/src/lib/auth/policy.ts index 8fdfef7..c21c799 100644 --- a/src/lib/auth/policy.ts +++ b/src/lib/auth/policy.ts @@ -21,12 +21,30 @@ export type Action = | "codes:update" /** Codes löschen. */ | "codes:delete" + /** + * Den Kurz-Link selbst vergeben oder ändern. + * + * Eigene Aktion und nicht Teil von `codes:update`, weil sie mehr wiegt: Ein + * Kurz-Link ist eine öffentliche Adresse. Wer ihn ändert, entwertet im + * Zweifel gedruckte Codes; wer ihn vergibt, besetzt eine Adresse, die + * jemand anderes gebraucht hätte. Deshalb standardmäßig nur für + * Administratoren — freigeben lässt es sich in der Verwaltung. + */ + | "codes:slug" /** Mitglieder und Rollen einer Gruppe verwalten. */ | "group:manage" /** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */ | "instance:manage"; -export type Scope = { groupId?: string }; +export type Scope = { + groupId?: string; + /** + * Die Einstellung aus der Verwaltung, ob Bearbeiter Kurz-Links vergeben + * dürfen. Wird von der aufrufenden Stelle mitgegeben, damit die Regeln hier + * ohne Datenbankzugriff auskommen und prüfbar bleiben. + */ + editorsMaySetSlug?: boolean; +}; const ROLE_RANK: Record = { VIEWER: 0, EDITOR: 1, ADMIN: 2 }; @@ -89,6 +107,12 @@ export function can(actor: Actor, action: Action, scope: Scope = {}): boolean { case "codes:update": case "codes:delete": return ROLE_RANK[role] >= ROLE_RANK.EDITOR; + case "codes:slug": + // Administratoren der Gruppe immer, Bearbeiter nur nach Freigabe. + if (role === "ADMIN") return true; + return ( + ROLE_RANK[role] >= ROLE_RANK.EDITOR && scope.editorsMaySetSlug === true + ); case "group:manage": return role === "ADMIN"; default: @@ -104,6 +128,8 @@ const DENIED_MESSAGES: Record = { "Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.", "codes:delete": "Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.", + "codes:slug": + "Den Kurz-Link darf hier nur ein Administrator vergeben oder ändern. Ein Administrator der Installation kann das unter Verwaltung → Einstellungen für Bearbeiter freigeben.", "group:manage": "Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.", "instance:manage": diff --git a/src/lib/codes.ts b/src/lib/codes.ts index 91df65f..4e00f1b 100644 --- a/src/lib/codes.ts +++ b/src/lib/codes.ts @@ -31,6 +31,7 @@ export type QrCodeWithRelations = QrCode & { group?: { name: string } | null; domain?: { hostname: string } | null; createdBy?: { name: string | null; email: string } | null; + aliases?: { slug: string }[]; }; export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto { @@ -60,6 +61,7 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto { return { id: code.id, slug: code.slug, + aliases: (code.aliases ?? []).map((alias) => alias.slug), type: code.type as QrTypeId, label: code.label, data, diff --git a/src/lib/dto.ts b/src/lib/dto.ts index 46543b1..d921b64 100644 --- a/src/lib/dto.ts +++ b/src/lib/dto.ts @@ -8,6 +8,8 @@ import type { QrTypeId } from "@/lib/qr/type-module"; export type QrCodeDto = { id: string; slug: string; + /** Frühere Kurz-Links, die weiterhin auf diesen Code führen. */ + aliases: string[]; type: QrTypeId; label: string | null; data: Record; diff --git a/src/lib/settings.ts b/src/lib/settings.ts new file mode 100644 index 0000000..6a23822 --- /dev/null +++ b/src/lib/settings.ts @@ -0,0 +1,45 @@ +import "server-only"; + +import { prisma } from "@/lib/db"; + +export type InstanceSettings = { + /** Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern? */ + editorsMaySetSlug: boolean; +}; + +export const DEFAULT_SETTINGS: InstanceSettings = { + editorsMaySetSlug: false, +}; + +/** + * Bewusst ohne Zwischenspeicher — dieselbe Erfahrung wie beim + * Erscheinungsbild: API-Route und Seite laufen in getrennten Modulinstanzen, + * ein Puffer wäre nach dem Speichern veraltet. Es ist eine Abfrage auf den + * Primärschlüssel einer Tabelle mit einer einzigen Zeile. + */ +export async function getSettings(): Promise { + try { + const zeile = await prisma.instanceSetting.findUnique({ + where: { id: "instance" }, + }); + return zeile + ? { editorsMaySetSlug: zeile.editorsMaySetSlug } + : DEFAULT_SETTINGS; + } catch (error) { + // Ein Datenbankproblem darf nicht dazu führen, dass keine Seite erscheint. + // Im Zweifel gilt die zurückhaltendere Einstellung. + console.error("Einstellungen konnten nicht geladen werden:", error); + return DEFAULT_SETTINGS; + } +} + +export async function saveSettings( + update: Partial, +): Promise { + await prisma.instanceSetting.upsert({ + where: { id: "instance" }, + update, + create: { id: "instance", ...update }, + }); + return getSettings(); +} diff --git a/src/lib/slug-resolve.ts b/src/lib/slug-resolve.ts new file mode 100644 index 0000000..fd093a3 --- /dev/null +++ b/src/lib/slug-resolve.ts @@ -0,0 +1,91 @@ +import "server-only"; + +import type { QrCode } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { QrRequestError } from "@/lib/qr/type-module"; +import { isValidSlug, normalizeSlug } from "@/lib/slug"; + +/** + * Sucht den Code zu einem Kurz-Link — einschließlich der früheren Adressen. + * + * Zwei Stufen, aus einem Grund: Der häufige Fall ist ein gescannter Code, und + * der liefert die Adresse genau so, wie sie gespeichert ist. Dieser Zugriff + * geht über den exakten Index. Erst wenn er nichts findet, wird + * großschreibungsunabhängig gesucht und danach unter den früheren Adressen — + * beides seltene Fälle, etwa wenn jemand die Adresse abtippt. + */ +export async function findCodeBySlug( + domainId: string, + slug: string, +): Promise<{ code: QrCode; ueberFrueheAdresse: boolean } | null> { + if (!isValidSlug(slug)) return null; + + const exakt = await prisma.qrCode.findUnique({ + where: { domainId_slug: { domainId, slug } }, + }); + if (exakt) return { code: exakt, ueberFrueheAdresse: false }; + + const normalisiert = normalizeSlug(slug); + + const egalGeschrieben = await prisma.qrCode.findFirst({ + where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } }, + }); + if (egalGeschrieben) { + return { code: egalGeschrieben, ueberFrueheAdresse: false }; + } + + const alias = await prisma.qrCodeAlias.findFirst({ + where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } }, + include: { qrCode: true }, + }); + if (alias) return { code: alias.qrCode, ueberFrueheAdresse: true }; + + return null; +} + +/** + * Ist dieser Kurz-Link auf dieser Domain noch frei? + * + * Belegt ist er auch dann, wenn ihn ein anderer Code als **frühere** Adresse + * hält — sonst könnte man einem fremden Code seine gedruckten Adressen + * wegnehmen. + */ +export async function assertSlugAvailable( + domainId: string, + slug: string, + /** Der eigene Code darf seine eigenen Adressen natürlich behalten. */ + eigenerCodeId?: string, +): Promise { + const normalisiert = normalizeSlug(slug); + + const belegtVomCode = await prisma.qrCode.findFirst({ + where: { + domainId, + slug: { equals: normalisiert, mode: "insensitive" }, + ...(eigenerCodeId ? { id: { not: eigenerCodeId } } : {}), + }, + select: { id: true }, + }); + if (belegtVomCode) { + throw new QrRequestError( + `Der Kurz-Link „${normalisiert}" ist auf dieser Domain schon vergeben.`, + 409, + ); + } + + const belegtAlsAlias = await prisma.qrCodeAlias.findFirst({ + where: { + domainId, + slug: { equals: normalisiert, mode: "insensitive" }, + ...(eigenerCodeId ? { qrCodeId: { not: eigenerCodeId } } : {}), + }, + select: { id: true }, + }); + if (belegtAlsAlias) { + throw new QrRequestError( + `Der Kurz-Link „${normalisiert}" gehört als frühere Adresse noch zu einem ` + + `anderen Code. Wer ihn freigibt, kann ihn danach hier verwenden.`, + 409, + ); + } +} diff --git a/src/lib/slug.ts b/src/lib/slug.ts index f5cffea..ce82055 100644 --- a/src/lib/slug.ts +++ b/src/lib/slug.ts @@ -8,8 +8,12 @@ const ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; export const SLUG_LENGTH = 8; -/** Muster, gegen das eingehende Slugs geprüft werden. */ -export const SLUG_PATTERN = /^[0-9A-Za-z]{4,32}$/; +/** Höchstens so viele durch `/` getrennte Abschnitte. */ +export const MAX_SLUG_SEGMENTS = 3; +/** Länge eines Abschnitts. */ +export const MAX_SEGMENT_LENGTH = 48; +/** Länge des ganzen Kurz-Links. */ +export const MAX_SLUG_LENGTH = 100; export function generateSlug(length: number = SLUG_LENGTH): string { let slug = ""; @@ -19,6 +23,90 @@ export function generateSlug(length: number = SLUG_LENGTH): string { return slug; } +/** + * Vergleichsform eines Kurz-Links. + * + * Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: Wer + * `qr.firma.de/r/Sommeraktion` abtippt, soll dort landen, wo + * `sommeraktion` liegt. Deshalb wird überall gegen diese Form verglichen — + * beim Auflösen wie bei der Prüfung auf Eindeutigkeit. + */ +export function normalizeSlug(value: string): string { + return value.trim().toLowerCase(); +} + +/** Ein einzelner Abschnitt: Kleinbuchstaben, Ziffern, Bindestriche. */ +const SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * Prüft einen Kurz-Link, wie er in der Adresse stehen darf. + * + * Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und `/` als Trenner — + * damit sind Adressen wie `kunde-a/sommeraktion` möglich. Bewusst **nicht** + * erlaubt: Umlaute und Akzente (sie werden in Adressen unleserlich kodiert), + * Leerzeichen, Unterstriche sowie Bindestriche am Rand eines Abschnitts. + * + * Selbst erzeugte Kurz-Links aus dem gemischten Alphabet oben bestehen diese + * Prüfung ebenfalls, weil verglichen wird, nachdem kleingeschrieben wurde. + * + * Führende, doppelte und abschließende Schrägstriche gelten als ungültig — und + * zwar auch beim Auflösen. Sonst führten `/r/sommer` und `/r//sommer` auf + * denselben Code, und es gäbe zwei Adressen für dieselbe Sache. + */ export function isValidSlug(value: string): boolean { - return SLUG_PATTERN.test(value); + const slug = normalizeSlug(value); + if (slug.length === 0 || slug.length > MAX_SLUG_LENGTH) return false; + if (slug.startsWith("/") || slug.endsWith("/") || slug.includes("//")) { + return false; + } + + const abschnitte = slug.split("/"); + if (abschnitte.length > MAX_SLUG_SEGMENTS) return false; + + return abschnitte.every( + (abschnitt) => + abschnitt.length > 0 && + abschnitt.length <= MAX_SEGMENT_LENGTH && + SEGMENT.test(abschnitt), + ); +} + +/** + * Prüft einen selbst gewählten Kurz-Link und liefert eine deutsche Begründung, + * wenn er nicht taugt. Ausführlicher als `isValidSlug`, weil hier jemand + * zusieht und wissen will, was falsch war. + */ +export function describeSlugProblem(eingabe: string): string | null { + const roh = eingabe.trim(); + if (roh.length === 0) return "Bitte gib einen Kurz-Link ein."; + + if (/^\/|\/$/.test(roh)) { + return "Der Kurz-Link darf nicht mit „/“ beginnen oder enden."; + } + if (roh.includes("//")) { + return "Zwei Schrägstriche hintereinander sind nicht möglich."; + } + if (roh.length > MAX_SLUG_LENGTH) { + return `Der Kurz-Link ist zu lang (höchstens ${MAX_SLUG_LENGTH} Zeichen).`; + } + + const slug = normalizeSlug(roh); + const abschnitte = slug.split("/"); + + if (abschnitte.length > MAX_SLUG_SEGMENTS) { + return `Höchstens ${MAX_SLUG_SEGMENTS} durch „/“ getrennte Abschnitte.`; + } + for (const abschnitt of abschnitte) { + if (abschnitt.length > MAX_SEGMENT_LENGTH) { + return `Jeder Abschnitt darf höchstens ${MAX_SEGMENT_LENGTH} Zeichen lang sein.`; + } + if (/^-|-$/.test(abschnitt)) { + return "Ein Bindestrich darf nicht am Anfang oder Ende eines Abschnitts stehen."; + } + if (!SEGMENT.test(abschnitt)) { + return "Erlaubt sind Buchstaben ohne Umlaute, Ziffern, Bindestriche und „/“ als Trenner."; + } + } + + return null; }