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; }