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
+27 -1
View File
@@ -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<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: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<Action, string> = {
"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":
+2
View File
@@ -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,
+2
View File
@@ -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<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;
/** 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;
}