Files
qrcode-generator/src/lib/slug.ts
T
Florian Wilde 46e7eb0b59 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.
2026-08-17 10:41:57 +02:00

113 lines
3.9 KiB
TypeScript

import { randomInt } from "node:crypto";
/**
* Alphabet ohne leicht verwechselbare Zeichen (0/O, 1/l/I). Kurz-URLs werden
* gelegentlich abgetippt — Verwechslungen wären hier besonders ärgerlich.
*/
const ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
export const SLUG_LENGTH = 8;
/** 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 = "";
for (let i = 0; i < length; i += 1) {
slug += ALPHABET[randomInt(ALPHABET.length)];
}
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 {
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;
}