Sprechende Kurz-Links: selbst vergeben, aendern, frueher gueltig halten

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.
This commit is contained in:
Florian Wilde
2026-08-17 10:41:57 +02:00
parent c38b27ca85
commit 46e7eb0b59
24 changed files with 917 additions and 49 deletions
+2
View File
@@ -31,6 +31,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
Admin-Bereich einstellbar. Admin-Bereich einstellbar.
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine - **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
eigene Wahl. 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 - **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
von selbst; alles Nötige steht in einer `.env`. von selbst; alles Nötige steht in einer `.env`.
+37 -5
View File
@@ -104,10 +104,41 @@ Browser landet.
| Braucht diesen Server | nie | dauerhaft | | Braucht diesen Server | nie | dauerhaft |
| In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler | | In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler |
Der **Slug** ist acht Zeichen lang aus einem Alphabet ohne verwechselbare ### Der Kurz-Link
Zeichen (kein `0`, `O`, `1`, `l`, `I`). Er ist **je Domain** eindeutig, nicht
global — derselbe Slug darf auf zwei Domains liegen und dort auf Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
Verschiedenes zeigen. 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 Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein
permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben — 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 | | Bereich | Was dort geht |
| --- | --- | | --- | --- |
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe | | **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
| **QR-Typen** | einzelne Typen ausblenden | | **QR-Typen** | einzelne Typen ausblenden |
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben | | **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 | | `POST /api/feedback` | Rückmeldung abgeben |
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern | | `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
| `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter | | `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 /r/{slug}` | Weiterleitung eines dynamischen Codes |
| `GET /s/{slug}` | öffentliche Landingpage | | `GET /s/{slug}` | öffentliche Landingpage |
+9
View File
@@ -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 Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne
Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl. Ä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) ### Dokumentation (15.08.2026)
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht. Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
@@ -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"));
@@ -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);
+39
View File
@@ -122,6 +122,7 @@ model Domain {
grants DomainGrant[] grants DomainGrant[]
codes QrCode[] codes QrCode[]
aliases QrCodeAlias[]
} }
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen /// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
@@ -194,6 +195,8 @@ model QrCode {
uploads Upload[] uploads Upload[]
/// Rückmeldungen, die über die Landingpage eingegangen sind. /// Rückmeldungen, die über die Landingpage eingegangen sind.
feedback Feedback[] feedback Feedback[]
/// Frühere Kurz-Links, die weiterhin hierher führen.
aliases QrCodeAlias[]
@@unique([domainId, slug]) @@unique([domainId, slug])
@@index([createdAt]) @@index([createdAt])
@@ -237,6 +240,42 @@ model Upload {
@@index([purpose]) @@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. /// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
/// ///
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner /// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
+12 -2
View File
@@ -4,6 +4,9 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator"; 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 { requirePageActor } from "@/lib/auth/guard";
import { getQrTypeState } from "@/lib/qr/availability"; import { getQrTypeState } from "@/lib/qr/availability";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
@@ -47,7 +50,7 @@ function Hinweis({
} }
export default async function CreatePage({ params }: Props) { export default async function CreatePage({ params }: Props) {
await requirePageActor(); const actor = await requirePageActor();
const { type } = await params; const { type } = await params;
const state = await getQrTypeState(type); const state = await getQrTypeState(type);
@@ -75,5 +78,12 @@ export default async function CreatePage({ params }: Props) {
); );
} }
return <QrGenerator typeId={state.qrType.id} />; // 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 <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
} }
+10 -2
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { CodesList } from "@/components/codes-list"; import { CodesList } from "@/components/codes-list";
import { can } from "@/lib/auth/policy";
import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard"; import { requirePageActor } from "@/lib/auth/guard";
export const metadata: Metadata = { title: "Meine Codes" }; export const metadata: Metadata = { title: "Meine Codes" };
@@ -8,10 +10,16 @@ export const metadata: Metadata = { title: "Meine Codes" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function MyCodesPage() { export default async function MyCodesPage() {
await requirePageActor(); const actor = await requirePageActor();
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte // Breiter als die übrigen Seiten: Die Liste soll viele Projekte
// nebeneinander zeigen können. // 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 ( return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10"> <div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7"> <div className="mb-7">
@@ -25,7 +33,7 @@ export default async function MyCodesPage() {
</p> </p>
</div> </div>
<CodesList /> <CodesList maySetSlug={maySetSlug} />
</div> </div>
); );
} }
@@ -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 (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/verwaltung"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Zur Verwaltung
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Einstellungen
</h1>
<p className="mt-2 text-slate-600">
Was Bearbeiter dürfen und was Administratoren vorbehalten bleibt.
</p>
</div>
<SettingsAdmin initial={{ editorsMaySetSlug: settings.editorsMaySetSlug }} />
</div>
);
}
+14 -1
View File
@@ -6,6 +6,7 @@ import { Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor"; import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePagePermission } from "@/lib/auth/guard"; import { requirePagePermission } from "@/lib/auth/guard";
import { getBranding } from "@/lib/branding"; import { getBranding } from "@/lib/branding";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Verwaltung" }; export const metadata: Metadata = { title: "Verwaltung" };
@@ -46,13 +47,14 @@ function Tile({
export default async function AdminPage() { export default async function AdminPage() {
await requirePagePermission("instance:manage"); await requirePagePermission("instance:manage");
const [userCount, groupCount, codeCount, domainCount, branding] = const [userCount, groupCount, codeCount, domainCount, branding, settings] =
await Promise.all([ await Promise.all([
prisma.user.count(), prisma.user.count(),
prisma.group.count(), prisma.group.count(),
prisma.qrCode.count(), prisma.qrCode.count(),
prisma.domain.count(), prisma.domain.count(),
getBranding(), getBranding(),
getSettings(),
]); ]);
const eigenes = [ const eigenes = [
@@ -120,6 +122,17 @@ export default async function AdminPage() {
description="Festlegen, welche Typen auf der Startseite erscheinen." description="Festlegen, welche Typen auf der Startseite erscheinen."
hint="15 Typen insgesamt" hint="15 Typen insgesamt"
/> />
<Tile
href="/verwaltung/einstellungen"
icon="lock"
title="Einstellungen"
description="Was Bearbeiter dürfen — etwa eigene Kurz-Links vergeben."
hint={
settings.editorsMaySetSlug
? "Kurz-Links: auch Bearbeiter"
: "Kurz-Links: nur Administratoren"
}
/>
<Tile <Tile
href="/verwaltung/erscheinungsbild" href="/verwaltung/erscheinungsbild"
icon="image" icon="image"
+40
View File
@@ -0,0 +1,40 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { getSettings, saveSettings } from "@/lib/settings";
export const dynamic = "force-dynamic";
const updateSchema = z.object({
editorsMaySetSlug: z.boolean().optional(),
});
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
return Response.json({ settings: await getSettings() });
} catch (error) {
return errorResponse(error);
}
}
export async function PATCH(request: NextRequest): Promise<Response> {
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);
}
}
+90 -5
View File
@@ -10,6 +10,9 @@ import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireImplementedQrType } from "@/lib/qr/availability"; import { requireImplementedQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request"; 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 { attachUploads } from "@/lib/uploads";
import { QrRequestError } from "@/lib/qr/type-module"; import { QrRequestError } from "@/lib/qr/type-module";
@@ -21,6 +24,7 @@ const WITH_RELATIONS = {
group: { select: { name: true } }, group: { select: { name: true } },
domain: { select: { hostname: true } }, domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } }, createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
} as const; } as const;
const updateSchema = z.object({ const updateSchema = z.object({
@@ -32,6 +36,17 @@ const updateSchema = z.object({
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.nullable() .nullable()
.optional(), .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) { async function loadCode(id: string) {
@@ -109,14 +124,84 @@ export async function PATCH(
Object.assign(update, parsed.data.design); 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."); return jsonError("Es wurden keine Änderungen übermittelt.");
} }
const updated = await prisma.qrCode.update({ // Umbenennen, alte Adresse sichern und Freigaben in einem Zug — sonst
where: { id }, // gäbe es einen Moment, in dem der Code unter keiner Adresse erreichbar
data: update, // ist, oder eine freigegebene Adresse bliebe hängen.
include: WITH_RELATIONS, 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 // Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
+32 -2
View File
@@ -12,7 +12,9 @@ import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
import { assertDynamicPossible } from "@/lib/qr/request"; import { assertDynamicPossible } from "@/lib/qr/request";
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module"; 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"; import { attachUploads } from "@/lib/uploads";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -26,6 +28,11 @@ const createSchema = z.object({
.trim() .trim()
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.optional(), .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. */ /** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
groupId: z.string().trim().min(1).optional(), groupId: z.string().trim().min(1).optional(),
/** Unter welcher Domain die Kurz-URL entsteht. */ /** Unter welcher Domain die Kurz-URL entsteht. */
@@ -57,6 +64,7 @@ export async function GET(): Promise<Response> {
group: { select: { name: true } }, group: { select: { name: true } },
domain: { select: { hostname: true } }, domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: 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<Response> {
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten. // Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
assertDynamicPossible(qrType, validated.data); 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. // Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
for (let attempt = 0; attempt < 5; attempt += 1) { for (let attempt = 0; attempt < 5; attempt += 1) {
try { try {
const created = await prisma.qrCode.create({ const created = await prisma.qrCode.create({
data: { data: {
slug: generateSlug(), slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
type: qrType.id, type: qrType.id,
label: label && label.length > 0 ? label : null, label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data), data: toJsonInput(validated.data),
@@ -114,6 +135,7 @@ export async function POST(request: NextRequest): Promise<Response> {
group: { select: { name: true } }, group: { select: { name: true } },
domain: { select: { hostname: true } }, domain: { select: { hostname: true } },
createdBy: { select: { name: true, email: true } }, createdBy: { select: { name: true, email: true } },
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
}, },
}); });
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code — // Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
@@ -122,6 +144,14 @@ export async function POST(request: NextRequest): Promise<Response> {
return Response.json({ code: toQrCodeDto(created) }, { status: 201 }); return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} catch (error) { } 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; if (!isUniqueViolation(error)) throw error;
} }
} }
@@ -4,10 +4,13 @@ import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains"; import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug"; import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic"; 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 { function notFound(message: string): Response {
return new Response( return new Response(
@@ -44,7 +47,8 @@ export async function GET(
request: NextRequest, request: NextRequest,
context: Context, context: Context,
): Promise<Response> { ): Promise<Response> {
const { slug } = await context.params; const abschnitte = (await context.params).slug;
const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) { if (!isValidSlug(slug)) {
return notFound("Diese Kurz-Adresse ist ungültig."); return notFound("Diese Kurz-Adresse ist ungültig.");
@@ -65,9 +69,8 @@ export async function GET(
); );
} }
const code = await prisma.qrCode.findUnique({ const gefunden = await findCodeBySlug(domain.id, slug);
where: { domainId_slug: { domainId: domain.id, slug } }, const code = gefunden?.code;
});
if (!code) { if (!code) {
return notFound( return notFound(
@@ -85,7 +88,7 @@ export async function GET(
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ, // Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch // damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
// wenn das eine nicht registrierte ist, etwa eine IP im LAN). // wenn das eine nicht registrierte ist, etwa eine IP im LAN).
target = `/s/${slug}`; target = `/s/${code.slug}`;
} else { } else {
try { try {
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran, // Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
@@ -3,14 +3,15 @@ import { headers } from "next/headers";
import { qrLandings } from "@/components/landing"; import { qrLandings } from "@/components/landing";
import { LandingNotice } from "@/components/landing/shell"; import { LandingNotice } from "@/components/landing/shell";
import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains"; import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry"; import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug"; import { isValidSlug } from "@/lib/slug";
import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic"; 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. * Ö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]`. * Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
* Sonst zählte jedes Neuladen dieser Seite als weiterer Scan. * 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; if (!isValidSlug(slug)) return null;
const matched = await resolveRequestDomain(await headers()); const matched = await resolveRequestDomain(await headers());
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true }; const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
if (!domain.isActive) return null; if (!domain.isActive) return null;
return prisma.qrCode.findUnique({ return (await findCodeBySlug(domain.id, slug))?.code ?? null;
where: { domainId_slug: { domainId: domain.id, slug } },
});
} }
export async function generateMetadata({ export async function generateMetadata({
+127 -12
View File
@@ -19,6 +19,7 @@ import {
import { ApiError, apiJson } from "@/lib/api-client"; import { ApiError, apiJson } from "@/lib/api-client";
import type { QrCodeDto } from "@/lib/dto"; import type { QrCodeDto } from "@/lib/dto";
import { qrTypes } from "@/lib/qr/registry"; import { qrTypes } from "@/lib/qr/registry";
import { describeSlugProblem } from "@/lib/slug";
import type { QrFormData } from "@/lib/qr/type-module"; import type { QrFormData } from "@/lib/qr/type-module";
const dateFormat = new Intl.DateTimeFormat("de-DE", { const dateFormat = new Intl.DateTimeFormat("de-DE", {
@@ -65,11 +66,19 @@ function displayName(code: QrCodeDto): string {
return code.label?.trim() || code.summary; 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<QrCodeDto[] | null>(null); const [codes, setCodes] = useState<QrCodeDto[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [editing, setEditing] = useState<QrCodeDto | null>(null); const [editing, setEditing] = useState<QrCodeDto | null>(null);
const [editSlug, setEditSlug] = useState("");
/** Welche früheren Adressen gelten sollen — was hier fehlt, wird freigegeben. */
const [editAliases, setEditAliases] = useState<string[]>([]);
const [editData, setEditData] = useState<QrFormData>({}); const [editData, setEditData] = useState<QrFormData>({});
const [editLabel, setEditLabel] = useState(""); const [editLabel, setEditLabel] = useState("");
const [editError, setEditError] = useState<string | null>(null); const [editError, setEditError] = useState<string | null>(null);
@@ -124,9 +133,19 @@ export function CodesList() {
setEditing(code); setEditing(code);
setEditData(code.data); setEditData(code.data);
setEditLabel(code.label ?? ""); setEditLabel(code.label ?? "");
setEditSlug(code.slug);
setEditAliases(code.aliases);
setEditError(null); 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 EditForm = editing ? qrForms[editing.type] : undefined;
const editValidation = useMemo( const editValidation = useMemo(
() => (editing ? qrTypes[editing.type].validate(editData) : null), () => (editing ? qrTypes[editing.type].validate(editData) : null),
@@ -139,12 +158,23 @@ export function CodesList() {
setEditError(editValidation.message); setEditError(editValidation.message);
return; return;
} }
if (slugProblem) {
setEditError(slugProblem);
return;
}
setSaving(true); setSaving(true);
setEditError(null); setEditError(null);
try { try {
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, { await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
method: "PATCH", 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); setEditing(null);
await load(); await load();
@@ -539,8 +569,8 @@ export function CodesList() {
<button <button
type="button" type="button"
onClick={() => openEdit(code)} onClick={() => openEdit(code)}
title="Ziel bearbeiten" title="Bearbeiten"
aria-label={`Ziel von ${displayName(code)} bearbeiten`} aria-label={`${displayName(code)} bearbeiten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900" className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
> >
<Icon name="pencil" className="size-4" /> <Icon name="pencil" className="size-4" />
@@ -683,7 +713,7 @@ export function CodesList() {
onClick={() => openEdit(code)} onClick={() => openEdit(code)}
> >
<Icon name="pencil" className="size-3.5" /> <Icon name="pencil" className="size-3.5" />
Ziel bearbeiten Bearbeiten
</Button> </Button>
<a <a
href={`/api/codes/${code.id}/qr?format=png&download=1`} href={`/api/codes/${code.id}/qr?format=png&download=1`}
@@ -721,7 +751,7 @@ export function CodesList() {
{/* ------------------------------------------------------ Bearbeiten */} {/* ------------------------------------------------------ Bearbeiten */}
<Modal <Modal
open={editing !== null} open={editing !== null}
title="Ziel bearbeiten" title="Code bearbeiten"
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
footer={ footer={
<> <>
@@ -742,12 +772,8 @@ export function CodesList() {
{editing ? ( {editing ? (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-600">
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt
auf{" "} gültig, solange der Kurz-Link derselbe ist.
<code className="font-mono text-xs break-all">
{editing.shortUrl}
</code>
.
</p> </p>
{EditForm ? ( {EditForm ? (
@@ -766,6 +792,95 @@ export function CodesList() {
</Notice> </Notice>
)} )}
{maySetSlug ? (
<>
<Field
label="Kurz-Link"
htmlFor="edit-slug"
hint="Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
>
<TextInput
id="edit-slug"
value={editSlug}
maxLength={100}
spellCheck={false}
invalid={Boolean(slugProblem)}
disabled={saving}
onChange={(event) => {
setEditSlug(event.target.value);
setEditError(null);
}}
/>
</Field>
{slugProblem ? (
<Notice tone="error">{slugProblem}</Notice>
) : slugGeaendert ? (
<Notice tone="info">
Der bisherige Kurz-Link{" "}
<code className="font-mono text-xs break-all">{editing.slug}</code>{" "}
bleibt gültig — schon gedruckte Codes funktionieren weiter. Unten
lässt er sich später freigeben.
</Notice>
) : null}
{editing.aliases.length > 0 ? (
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
Frühere Kurz-Links
</span>
<p className="mb-2 text-sm text-slate-500">
Führen weiterhin auf diesen Code. Freigeben nur, wenn damit
nichts gedruckt ist — die Adresse ist danach sofort tot.
</p>
<ul className="space-y-1.5">
{editing.aliases.map((alias) => {
const behalten = editAliases.includes(alias);
return (
<li
key={alias}
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2"
>
<code
className={
behalten
? "min-w-0 font-mono text-xs break-all text-slate-700"
: "min-w-0 font-mono text-xs break-all text-slate-400 line-through"
}
>
/r/{alias}
</code>
<Button
type="button"
variant={behalten ? "ghost" : "secondary"}
size="sm"
disabled={saving}
onClick={() =>
setEditAliases((alt) =>
behalten
? alt.filter((eintrag) => eintrag !== alias)
: [...alt, alias],
)
}
>
{behalten ? "Freigeben" : "Doch behalten"}
</Button>
</li>
);
})}
</ul>
{freigegeben.length > 0 ? (
<Notice tone="warning">
{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.`}
</Notice>
) : null}
</div>
) : null}
</>
) : null}
<Field label="Name (optional)" htmlFor="edit-label"> <Field label="Name (optional)" htmlFor="edit-label">
<TextInput <TextInput
id="edit-label" id="edit-label"
+41 -4
View File
@@ -22,6 +22,7 @@ import type { QrCodeDto } from "@/lib/dto";
import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design"; import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design";
import { qrTypes } from "@/lib/qr/registry"; import { qrTypes } from "@/lib/qr/registry";
import type { QrFormData, QrTypeId } from "@/lib/qr/type-module"; import type { QrFormData, QrTypeId } from "@/lib/qr/type-module";
import { describeSlugProblem } from "@/lib/slug";
type PreviewState = { dataUrl: string; content: string }; type PreviewState = { dataUrl: string; content: string };
@@ -42,7 +43,14 @@ function sameData(a: unknown, b: unknown): boolean {
return stableStringify(a) === stableStringify(b); return stableStringify(a) === stableStringify(b);
} }
export function QrGenerator({ typeId }: { typeId: QrTypeId }) { export function QrGenerator({
typeId,
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
maySetSlug = true,
}: {
typeId: QrTypeId;
maySetSlug?: boolean;
}) {
const qrType = qrTypes[typeId]; const qrType = qrTypes[typeId];
const Form = qrForms[typeId]; const Form = qrForms[typeId];
@@ -51,6 +59,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
// Typen ohne statische Variante — etwa der App-Link — starten dynamisch. // Typen ohne statische Variante — etwa der App-Link — starten dynamisch.
const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic); const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic);
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
/** Selbst gewählter Kurz-Link. Leer bedeutet: einen erzeugen lassen. */
const [wunschSlug, setWunschSlug] = useState("");
const [saved, setSaved] = useState<QrCodeDto | null>(null); const [saved, setSaved] = useState<QrCodeDto | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -164,6 +174,11 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
/* ----------------------------------------------- Dynamischen Code speichern */ /* ----------------------------------------------- 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 = const hasUnsavedChanges =
saved !== null && saved !== null &&
(!sameData(saved.data, validation.ok ? validation.data : data) || (!sameData(saved.data, validation.ok ? validation.data : data) ||
@@ -171,7 +186,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
(saved.label ?? "") !== label.trim()); (saved.label ?? "") !== label.trim());
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!validation.ok || !designValid) return; if (!validation.ok || !designValid || slugProblem) return;
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
@@ -180,6 +195,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
data: validation.data, data: validation.data,
design, design,
label: label.trim() || undefined, label: label.trim() || undefined,
slug: wunschSlug.trim() || undefined,
}; };
const result = saved const result = saved
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, { ? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
@@ -200,7 +216,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [validation, designValid, typeId, design, label, saved]); }, [validation, designValid, slugProblem, typeId, design, label, wunschSlug, saved]);
const handleCopy = useCallback(async () => { const handleCopy = useCallback(async () => {
if (!saved) return; if (!saved) return;
@@ -347,6 +363,27 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
/> />
</Field> </Field>
{/* Nur beim Anlegen: Danach steht der Kurz-Link im
Bearbeiten-Dialog, wo auch die früheren Adressen liegen. */}
{saved || !maySetSlug ? null : (
<Field
label="Kurz-Link (optional)"
htmlFor="code-slug"
error={slugProblem}
hint="Leer lassen, dann wird einer erzeugt. Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
>
<TextInput
id="code-slug"
value={wunschSlug}
maxLength={100}
spellCheck={false}
invalid={Boolean(slugProblem)}
placeholder="z. B. sommeraktion oder kunde-a/flyer"
onChange={(event) => setWunschSlug(event.target.value)}
/>
</Field>
)}
{saved ? ( {saved ? (
<div className="space-y-3"> <div className="space-y-3">
<Notice tone="success"> <Notice tone="success">
@@ -408,7 +445,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<Button <Button
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={saving || !ready} disabled={saving || !ready || Boolean(slugProblem)}
> >
{saving ? "Speichert …" : "Dynamischen Code speichern"} {saving ? "Speichert …" : "Dynamischen Code speichern"}
</Button> </Button>
+84
View File
@@ -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<string | null>(null);
const [hinweis, setHinweis] = useState<string | null>(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 (
<div className="space-y-5">
{fehler ? <Notice tone="error">{fehler}</Notice> : null}
{hinweis ? <Notice tone="success">{hinweis}</Notice> : null}
<Card>
<CardTitle hint="Gilt für alle Gruppen dieser Installation.">
Kurz-Links
</CardTitle>
<Checkbox
id="editors-may-set-slug"
label="Bearbeiter dürfen den Kurz-Link selbst vergeben und ändern"
hint="Aus: Nur Administratoren. Bearbeiter legen Codes an, der Kurz-Link wird dabei erzeugt."
checked={wert.editorsMaySetSlug}
disabled={laeuft}
onChange={(event) => void umschalten(event.target.checked)}
/>
<div className="mt-4 space-y-2 text-sm text-slate-600">
<p>
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.
</p>
<p>
Deshalb ist das standardmäßig Administratoren vorbehalten. In einem
kleinen Team, in dem alle wissen, was ein Kurz-Link bedeutet, ist
die Freigabe unbedenklich.
</p>
</div>
</Card>
</div>
);
}
+27 -1
View File
@@ -21,12 +21,30 @@ export type Action =
| "codes:update" | "codes:update"
/** Codes löschen. */ /** Codes löschen. */
| "codes:delete" | "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. */ /** Mitglieder und Rollen einer Gruppe verwalten. */
| "group:manage" | "group:manage"
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */ /** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
| "instance:manage"; | "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<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 }; const ROLE_RANK: Record<Role, number> = { 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:update":
case "codes:delete": case "codes:delete":
return ROLE_RANK[role] >= ROLE_RANK.EDITOR; 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": case "group:manage":
return role === "ADMIN"; return role === "ADMIN";
default: default:
@@ -104,6 +128,8 @@ const DENIED_MESSAGES: Record<Action, string> = {
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.", "Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"codes:delete": "codes:delete":
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.", "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": "group:manage":
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.", "Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
"instance:manage": "instance:manage":
+2
View File
@@ -31,6 +31,7 @@ export type QrCodeWithRelations = QrCode & {
group?: { name: string } | null; group?: { name: string } | null;
domain?: { hostname: string } | null; domain?: { hostname: string } | null;
createdBy?: { name: string | null; email: string } | null; createdBy?: { name: string | null; email: string } | null;
aliases?: { slug: string }[];
}; };
export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto { export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
@@ -60,6 +61,7 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
return { return {
id: code.id, id: code.id,
slug: code.slug, slug: code.slug,
aliases: (code.aliases ?? []).map((alias) => alias.slug),
type: code.type as QrTypeId, type: code.type as QrTypeId,
label: code.label, label: code.label,
data, data,
+2
View File
@@ -8,6 +8,8 @@ import type { QrTypeId } from "@/lib/qr/type-module";
export type QrCodeDto = { export type QrCodeDto = {
id: string; id: string;
slug: string; slug: string;
/** Frühere Kurz-Links, die weiterhin auf diesen Code führen. */
aliases: string[];
type: QrTypeId; type: QrTypeId;
label: string | null; label: string | null;
data: Record<string, unknown>; data: Record<string, unknown>;
+45
View File
@@ -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<InstanceSettings> {
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<InstanceSettings>,
): Promise<InstanceSettings> {
await prisma.instanceSetting.upsert({
where: { id: "instance" },
update,
create: { id: "instance", ...update },
});
return getSettings();
}
+91
View File
@@ -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<void> {
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,
);
}
}
+91 -3
View File
@@ -8,8 +8,12 @@ const ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
export const SLUG_LENGTH = 8; export const SLUG_LENGTH = 8;
/** Muster, gegen das eingehende Slugs geprüft werden. */ /** Höchstens so viele durch `/` getrennte Abschnitte. */
export const SLUG_PATTERN = /^[0-9A-Za-z]{4,32}$/; 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 { export function generateSlug(length: number = SLUG_LENGTH): string {
let slug = ""; let slug = "";
@@ -19,6 +23,90 @@ export function generateSlug(length: number = SLUG_LENGTH): string {
return slug; 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 { 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;
} }