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.
113 lines
3.9 KiB
TypeScript
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;
|
|
}
|