diff --git a/README.md b/README.md index ef02d13..29aaad0 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Benutzername und Passwort. - Lange URL eintragen → Kurzlink erzeugen - Zufälliger Slug (kollisionssicher, 7 Zeichen, wächst bei Kollisionen automatisch) oder eigener Wunsch-Slug wie `deine-domain.de/messe2026` +- Mehrteilige Slugs mit Schrägstrich (`g/1AEJ0X90ZmF`) und erhaltener Groß-/Kleinschreibung – + damit lassen sich bereits gedruckte QR-Codes aus einem anderen System übernehmen - URL-Validierung: ausschließlich `http` und `https` – innerhalb davon aber großzügig (Umlaut-Domains, Umlaute in Pfad/Query, IPv6, interne Hostnamen ohne Punkt) - Weiterleitung per HTTP 301/302/307/308 (konfigurierbar) @@ -394,9 +396,31 @@ Beim ersten Aufruf erscheint der Setup-Assistent (sofern kein Hostname nach Punycode gewandelt (`müller.de` → `xn--mller-kva.de`), weil HTTP-Header laut RFC 7230 ASCII sein müssen. Pfad und Query werden beim Speichern prozentkodiert, ohne bereits kodierte Sequenzen ein zweites Mal zu kodieren. -- **Wunsch-Slug** – optional. Leer lassen für einen zufälligen Slug. Eingaben werden - normalisiert (Großbuchstaben und Umlaute werden umgesetzt, Sonderzeichen zu - Bindestrichen) und auf Eindeutigkeit geprüft. +- **Wunsch-Slug** – optional. Leer lassen für einen zufälligen Slug. + + Erlaubt sind Buchstaben, Ziffern, Bindestrich, Unterstrich und Punkt. Der + **Schrägstrich trennt Abschnitte**, sodass sich auch mehrteilige Adressen abbilden + lassen: + + | Eingabe | Ergebnis | + |---|---| + | `messe2026` | `deine-domain.de/messe2026` | + | `g/1AEJ0X90ZmF` | `deine-domain.de/g/1AEJ0X90ZmF` | + | `Flyer2026/Halle-3` | `deine-domain.de/Flyer2026/Halle-3` | + | `https://alt.de/g/ABC` | wird auf `g/ABC` reduziert | + + **Groß- und Kleinschreibung bleibt erhalten.** Das ist wichtig, um bereits gedruckte + QR-Codes aus einem anderen System zu übernehmen – deren Pfade lassen sich + nachträglich nicht mehr ändern. Beim *Aufruf* wird die Schreibweise zusätzlich ohne + Beachtung erkannt, falls jemand den Code abtippt. + + Zwei Links, die sich **nur** in der Schreibweise unterscheiden, sind auf derselben + Domain nicht möglich (`Messe` und `messe` schließen sich aus) – sonst wäre an einer + gedruckten Adresse nicht mehr erkennbar, welcher gemeint ist. + + Umlaute und Leerzeichen werden weiterhin umgeschrieben (`Grüße 2026` → `Gruesse-2026`). + Der erste Abschnitt darf nicht `admin`, `login`, `assets`, `.well-known` usw. lauten, + damit die Verwaltung nicht verdeckt wird. - **Interne Bezeichnung** – nur zur eigenen Orientierung, nirgends öffentlich. - **Aktiv** – deaktivierte Links liefern die 404-Seite, behalten aber ihre Statistik. diff --git a/src/App.php b/src/App.php index 716954b..6f68852 100644 --- a/src/App.php +++ b/src/App.php @@ -212,7 +212,10 @@ final class App $router->get(DomainChecker::PATH, [$domainController, 'verify']); // --- Oeffentlich: ausschliesslich die Weiterleitung ------------- - $router->get('/{slug}', [$redirect, 'handle']); + // Bewusst ganz am Ende und mehrteilig: Kurzlinks duerfen wie + // "g/1AEJ0X90ZmF" aussehen. Alle Routen darueber gewinnen, weil + // der Router in Registrierungsreihenfolge sucht. + $router->get('/{slug:path}', [$redirect, 'handle']); $router->fallback([$redirect, 'notFound']); return $router; diff --git a/src/Controller/RedirectController.php b/src/Controller/RedirectController.php index 0a06299..d42f2f5 100644 --- a/src/Controller/RedirectController.php +++ b/src/Controller/RedirectController.php @@ -49,9 +49,9 @@ final class RedirectController extends Controller ); $domainId = (int) $domain['id']; - // Bequemlichkeit: /abc/ und /ABC finden denselben Link. - $link = $this->links->findBySlug($slug, $domainId) - ?? $this->links->findBySlug(strtolower($slug), $domainId); + // findBySlug sucht zuerst exakt und faellt dann auf einen + // Vergleich ohne Ruecksicht auf Gross-/Kleinschreibung zurueck. + $link = $this->links->findBySlug($slug, $domainId); if ($link === null || (int) $link['is_active'] !== 1) { return $this->notFound($request, ['slug' => $slug]); diff --git a/src/Http/Router.php b/src/Http/Router.php index 1661e39..13bff59 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -31,15 +31,21 @@ final class Router $this->fallback = $handler; } + /** + * Platzhalter: + * {name} ein Pfadsegment (keine Schraegstriche) + * {name:path} mehrere Segmente, Schraegstriche eingeschlossen - + * fuer Kurzlinks wie "g/1AEJ0X90ZmF" + */ private function add(string $method, string $pattern, callable $handler): void { $params = []; $regex = preg_replace_callback( - '#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', + '#\{([a-zA-Z_][a-zA-Z0-9_]*)(:path)?\}#', static function (array $matches) use (&$params): string { $params[] = $matches[1]; - return '([^/]+)'; + return ($matches[2] ?? '') === ':path' ? '(.+)' : '([^/]+)'; }, $pattern, ) ?? $pattern; diff --git a/src/Repository/LinkRepository.php b/src/Repository/LinkRepository.php index a3ea1b8..7283b3b 100644 --- a/src/Repository/LinkRepository.php +++ b/src/Repository/LinkRepository.php @@ -21,16 +21,39 @@ final class LinkRepository /** * Ein Slug ist nur innerhalb seiner Domain eindeutig. * + * Zuerst wird exakt gesucht (ueber den Index). Nur wenn das nichts + * ergibt, folgt ein Versuch ohne Ruecksicht auf Gross- und + * Kleinschreibung - gedruckte Codes werden gelegentlich falsch + * abgetippt. Weil beim Anlegen bereits ohne Ruecksicht auf die + * Schreibweise auf Eindeutigkeit geprueft wird, kann dieser zweite + * Versuch nie mehrdeutig sein. + * * @return array|null */ public function findBySlug(string $slug, int $domainId): ?array { - return $this->db->fetchOne( + $link = $this->db->fetchOne( 'SELECT l.*, ' . self::DOMAIN_COLUMNS . ' FROM links l LEFT JOIN domains d ON d.id = l.domain_id WHERE l.slug = ? AND l.domain_id = ?', [$slug, $domainId], ); + + if ($link !== null) { + return $link; + } + + // Der zweite Versuch ist auch dann noetig, wenn die Anfrage + // bereits klein geschrieben ist - gespeichert sein kann + // "g/1AEJ0X90ZmF", aufgerufen wird "g/1aej0x90zmf". + $lower = mb_strtolower($slug, 'UTF-8'); + + return $this->db->fetchOne( + 'SELECT l.*, ' . self::DOMAIN_COLUMNS . ' + FROM links l LEFT JOIN domains d ON d.id = l.domain_id + WHERE LOWER(l.slug) = ? AND l.domain_id = ?', + [$lower, $domainId], + ); } /** @@ -46,18 +69,27 @@ final class LinkRepository ); } + /** + * Ohne Ruecksicht auf Gross- und Kleinschreibung: "Messe" und + * "messe" sollen auf derselben Domain nicht nebeneinander bestehen. + * Sonst waere an einer gedruckten Adresse nicht mehr zu erkennen, + * welcher Link gemeint ist - und MySQL wuerde sie je nach Kollation + * ohnehin als gleich ansehen. + */ public function slugExists(string $slug, int $domainId, ?int $exceptId = null): bool { + $lower = mb_strtolower($slug, 'UTF-8'); + if ($exceptId === null) { return $this->db->fetchValue( - 'SELECT 1 FROM links WHERE slug = ? AND domain_id = ?', - [$slug, $domainId], + 'SELECT 1 FROM links WHERE LOWER(slug) = ? AND domain_id = ?', + [$lower, $domainId], ) !== null; } return $this->db->fetchValue( - 'SELECT 1 FROM links WHERE slug = ? AND domain_id = ? AND id <> ?', - [$slug, $domainId, $exceptId], + 'SELECT 1 FROM links WHERE LOWER(slug) = ? AND domain_id = ? AND id <> ?', + [$lower, $domainId, $exceptId], ) !== null; } diff --git a/src/Support/Slug.php b/src/Support/Slug.php index d882d0d..4491c74 100644 --- a/src/Support/Slug.php +++ b/src/Support/Slug.php @@ -5,32 +5,52 @@ declare(strict_types=1); namespace App\Support; use InvalidArgumentException; +use RuntimeException; /** * Erzeugung und Validierung von Slugs. + * + * Ein Slug darf mehrteilig sein ("g/1AEJ0X90ZmF") und Gross- und + * Kleinschreibung enthalten. Beides ist noetig, um bereits gedruckte + * QR-Codes aus anderen Systemen weiterzuverwenden: deren Pfade lassen + * sich nachtraeglich nicht mehr aendern. + * + * Gross- und Kleinschreibung bleibt deshalb erhalten, wie sie + * eingegeben wurde. Die Eindeutigkeit wird aber trotzdem OHNE Ruecksicht + * darauf geprueft (siehe LinkRepository): "Messe" und "messe" sollen + * nicht nebeneinander existieren, sonst waere nicht mehr erkennbar, + * welcher Link gemeint ist. */ final class Slug { public const MIN_LENGTH = 1; - public const MAX_LENGTH = 64; + public const MAX_LENGTH = 200; + + /** Hoechstzahl der durch "/" getrennten Abschnitte. */ + public const MAX_SEGMENTS = 8; + + /** Hoechstlaenge eines einzelnen Abschnitts. */ + public const MAX_SEGMENT_LENGTH = 64; /** * Kleinbuchstaben plus Ziffern 2-9. Die Ziffern 0 und 1 fehlen, weil * sie in gedruckten Kurzlinks mit "o" und "l" verwechselt werden - - * da Slugs immer klein geschrieben sind, bleiben die Buchstaben - * dadurch eindeutig. Ergibt 34 Zeichen, also rund 5,2e10 Kombinationen - * bei sieben Stellen. + * da erzeugte Slugs immer klein geschrieben sind, bleiben die + * Buchstaben dadurch eindeutig. Ergibt 34 Zeichen, also rund 5,2e10 + * Kombinationen bei sieben Stellen. */ private const ALPHABET = 'abcdefghijklmnopqrstuvwxyz23456789'; /** - * Reservierte Pfade, die nicht als Slug vergeben werden duerfen. + * Reservierte erste Abschnitte. Ein Slug darf damit nicht beginnen, + * sonst wuerde er die Verwaltung oder statische Dateien verdecken. * * @var list */ public const RESERVED = [ 'admin', 'login', 'logout', 'setup', 'assets', 'uploads', 'api', 'favicon.ico', 'robots.txt', 'health', 'index', + '.well-known', ]; /** @@ -38,7 +58,7 @@ final class Slug */ public static function random(int $length = 7): string { - if ($length < 4 || $length > self::MAX_LENGTH) { + if ($length < 4 || $length > self::MAX_SEGMENT_LENGTH) { throw new InvalidArgumentException('Slug-Laenge muss zwischen 4 und 64 liegen.'); } @@ -63,49 +83,65 @@ final class Slug { for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { // Alle 4 Fehlversuche ein Zeichen mehr. - $candidate = self::random(min($length + intdiv($attempt, 4), self::MAX_LENGTH)); + $candidate = self::random(min($length + intdiv($attempt, 4), self::MAX_SEGMENT_LENGTH)); - if (!in_array($candidate, self::RESERVED, true) && !$exists($candidate)) { + if (!self::isReserved($candidate) && !$exists($candidate)) { return $candidate; } } - throw new \RuntimeException('Es konnte kein freier Slug erzeugt werden.'); + throw new RuntimeException('Es konnte kein freier Slug erzeugt werden.'); } /** - * Normalisiert einen Wunsch-Slug: Kleinbuchstaben, Umlaute ersetzt, - * alle uebrigen Zeichen zu Bindestrichen. + * Bereitet eine Eingabe auf, ohne ihre Aussage zu veraendern: + * Umlaute werden umgeschrieben, unzulaessige Zeichen zu Bindestrichen, + * mehrfache Schraegstriche zusammengefasst. + * + * Gross- und Kleinschreibung bleibt erhalten - ein bereits gedruckter + * Code wie "g/1AEJ0X90ZmF" muss unveraendert eingetragen werden koennen. */ public static function normalize(string $input): string { $input = trim($input); + if ($input === '') { + return ''; + } + + // Versehentlich eingefuegte vollstaendige URL: nur den Pfad nehmen. + if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*://[^/]+(/.*)?$#', $input, $matches) === 1) { + $input = $matches[1] ?? ''; + } + $input = strtr($input, [ 'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss', - 'Ä' => 'ae', 'Ö' => 'oe', 'Ü' => 'ue', + 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', ]); - $input = strtolower($input); - $input = preg_replace('/[^a-z0-9-]+/', '-', $input) ?? ''; - $input = preg_replace('/-{2,}/', '-', $input) ?? ''; - return trim($input, '-'); + // Alles ausser den erlaubten Zeichen wird zum Bindestrich. + $input = preg_replace('#[^A-Za-z0-9._~/-]+#', '-', $input) ?? ''; + // Mehrfache Trenner zusammenfassen. + $input = preg_replace('#/{2,}#', '/', $input) ?? ''; + $input = preg_replace('#-{2,}#', '-', $input) ?? ''; + + $segments = []; + foreach (explode('/', $input) as $segment) { + $segment = trim($segment, '-._~'); + if ($segment !== '') { + $segments[] = $segment; + } + } + + return implode('/', $segments); } /** - * Erlaubt: a-z, 0-9 und Bindestrich; nicht am Anfang/Ende, - * keine doppelten Bindestriche, 1-64 Zeichen. + * Erlaubt sind Buchstaben, Ziffern, Bindestrich, Unterstrich und + * Punkt; mehrere Abschnitte werden durch "/" getrennt. */ public static function isValid(string $slug): bool { - if ($slug === '' || strlen($slug) > self::MAX_LENGTH) { - return false; - } - - if (in_array($slug, self::RESERVED, true)) { - return false; - } - - return (bool) preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug); + return self::validationError($slug) === null; } /** @@ -121,18 +157,67 @@ final class Slug return sprintf('Der Slug darf hoechstens %d Zeichen lang sein.', self::MAX_LENGTH); } - if (in_array($slug, self::RESERVED, true)) { + if (str_starts_with($slug, '/') || str_ends_with($slug, '/')) { + return 'Der Slug darf nicht mit einem Schraegstrich beginnen oder enden.'; + } + + if (!preg_match('#^[A-Za-z0-9._~/-]+$#', $slug)) { + return 'Erlaubt sind Buchstaben, Ziffern, Bindestrich, Unterstrich, Punkt ' + . 'und der Schraegstrich als Trenner.'; + } + + $segments = explode('/', $slug); + + if (count($segments) > self::MAX_SEGMENTS) { + return sprintf('Der Slug darf hoechstens %d durch "/" getrennte Abschnitte haben.', self::MAX_SEGMENTS); + } + + if (self::isReserved($slug)) { return 'Dieser Slug ist reserviert und kann nicht verwendet werden.'; } - if (!preg_match('/^[a-z0-9-]+$/', $slug)) { - return 'Erlaubt sind nur Kleinbuchstaben (a-z), Ziffern (0-9) und Bindestriche.'; - } + foreach ($segments as $segment) { + if ($segment === '') { + return 'Der Slug darf keine leeren Abschnitte enthalten (kein "//").'; + } - if (!self::isValid($slug)) { - return 'Der Slug darf nicht mit einem Bindestrich beginnen oder enden und keine doppelten Bindestriche enthalten.'; + if (strlen($segment) > self::MAX_SEGMENT_LENGTH) { + return sprintf( + 'Jeder Abschnitt darf hoechstens %d Zeichen lang sein.', + self::MAX_SEGMENT_LENGTH, + ); + } + + if ($segment === '.' || $segment === '..') { + return 'Die Abschnitte "." und ".." sind nicht erlaubt.'; + } + + // Am Anfang und Ende eines Abschnitts nur Buchstaben/Ziffern - + // sonst entstehen Adressen wie "/-abc" oder "/abc." + if (preg_match('#^[A-Za-z0-9]([A-Za-z0-9._~-]*[A-Za-z0-9])?$#', $segment) !== 1) { + return 'Jeder Abschnitt muss mit einem Buchstaben oder einer Ziffer ' + . 'beginnen und enden.'; + } } return null; } + + /** + * Ist der erste Abschnitt fuer die Anwendung reserviert? + */ + public static function isReserved(string $slug): bool + { + $first = strtolower(explode('/', $slug)[0]); + + return in_array($first, self::RESERVED, true); + } + + /** + * Vergleichsform fuer die Eindeutigkeitspruefung. + */ + public static function comparisonKey(string $slug): string + { + return strtolower($slug); + } } diff --git a/templates/links/form.php b/templates/links/form.php index c4e2667..76e9164 100644 --- a/templates/links/form.php +++ b/templates/links/form.php @@ -81,11 +81,17 @@ foreach ($domains as $domain) {
/ >
-

Erlaubt sind Kleinbuchstaben, Ziffern und Bindestriche.

+

+ Buchstaben, Ziffern, Bindestrich, Unterstrich und Punkt. Der Schrägstrich trennt + Abschnitte, sodass sich auch Adressen wie g/1AEJ0X90ZmF abbilden lassen – + praktisch, um bereits gedruckte QR-Codes aus einem anderen System zu übernehmen. + Groß- und Kleinschreibung bleibt erhalten und muss beim Anlegen + exakt stimmen; beim Aufruf wird sie zur Sicherheit auch ohne Beachtung erkannt. +

diff --git a/tests/RedirectTest.php b/tests/RedirectTest.php index 464daca..f2dd28c 100644 --- a/tests/RedirectTest.php +++ b/tests/RedirectTest.php @@ -326,6 +326,95 @@ final class RedirectTest extends TestCase self::assertSame(302, $response->status()); } + // --- Mehrteilige Slugs aus fremden Systemen ----------------------- + + public function testPathSlugIsStoredAndRedirected(): void + { + $this->links->create($this->defaultDomainId, 'g/1AEJ0X90ZmF', 'https://www.beispiel.de/uebernommen'); + + $response = $this->controller()->handle( + $this->request('/g/1AEJ0X90ZmF'), + ['slug' => 'g/1AEJ0X90ZmF'], + ); + + self::assertSame(302, $response->status()); + self::assertSame('https://www.beispiel.de/uebernommen', $response->headers()['Location']); + } + + public function testLetterCaseIsPreservedExactly(): void + { + $id = $this->links->create($this->defaultDomainId, 'g/1AEJ0X90ZmF', 'https://www.beispiel.de/'); + + self::assertSame('g/1AEJ0X90ZmF', $this->links->find($id)['slug']); + } + + public function testPathSlugAlsoWorksWithWrongLetterCase(): void + { + // Abgetippte Codes stimmen nicht immer in der Schreibweise. + $this->links->create($this->defaultDomainId, 'g/1AEJ0X90ZmF', 'https://www.beispiel.de/'); + + $response = $this->controller()->handle( + $this->request('/g/1aej0x90zmf'), + ['slug' => 'g/1aej0x90zmf'], + ); + + self::assertSame(302, $response->status()); + } + + public function testSlugsDifferingOnlyInCaseAreConsideredTaken(): void + { + $this->links->create($this->defaultDomainId, 'Messe', 'https://www.beispiel.de/a'); + + // Sonst waere an einer gedruckten Adresse nicht mehr erkennbar, + // welcher Link gemeint ist. + self::assertTrue($this->links->slugExists('messe', $this->defaultDomainId)); + self::assertTrue($this->links->slugExists('MESSE', $this->defaultDomainId)); + self::assertTrue($this->links->slugExists('Messe', $this->defaultDomainId)); + self::assertFalse($this->links->slugExists('messe2', $this->defaultDomainId)); + } + + public function testPathSlugsAreUniquePerDomain(): void + { + $qrId = $this->domains->create('https://qr.example.de', null, false); + + $this->links->create($this->defaultDomainId, 'g/ABC', 'https://www.beispiel.de/a'); + + self::assertTrue($this->links->slugExists('g/ABC', $this->defaultDomainId)); + self::assertFalse($this->links->slugExists('g/ABC', $qrId)); + + $this->links->create($qrId, 'g/ABC', 'https://www.beispiel.de/b'); + + $viaQr = $this->controller()->handle( + $this->request('/g/ABC', host: 'qr.example.de'), + ['slug' => 'g/ABC'], + ); + + self::assertSame('https://www.beispiel.de/b', $viaQr->headers()['Location']); + } + + public function testUnknownPathSlugGivesFriendly404(): void + { + $response = $this->controller()->handle( + $this->request('/g/gibtesnicht'), + ['slug' => 'g/gibtesnicht'], + ); + + self::assertSame(404, $response->status()); + self::assertStringContainsString('g/gibtesnicht', $response->body()); + } + + public function testClicksAreCountedForPathSlugs(): void + { + $id = $this->links->create($this->defaultDomainId, 'g/1AEJ0X90ZmF', 'https://www.beispiel.de/'); + + $this->controller() + ->handle($this->request('/g/1AEJ0X90ZmF'), ['slug' => 'g/1AEJ0X90ZmF']) + ->runAfterSend(); + + self::assertSame(1, (int) $this->links->find($id)['click_count']); + self::assertSame(1, $this->visits->totalForLink($id)); + } + public function testUmlautTargetIsRedirectedAsPunycode(): void { $this->links->create($this->defaultDomainId, 'umlaut', 'https://müller.de/preise'); diff --git a/tests/SlugTest.php b/tests/SlugTest.php index 10b9883..2e41185 100644 --- a/tests/SlugTest.php +++ b/tests/SlugTest.php @@ -91,24 +91,39 @@ final class SlugTest extends TestCase public static function slugValidationProvider(): array { return [ + // --- zulaessig ------------------------------------------ 'einfach' => ['messe2026', true], 'mit Bindestrich' => ['messe-2026-halle-3', true], 'nur Ziffern' => ['12345', true], 'einzelnes Zeichen' => ['a', true], + 'Grossbuchstaben' => ['Messe2026', true], + 'gemischte Schreibweise' => ['1AEJ0X90ZmF', true], + 'Unterstrich' => ['messe_2026', true], + 'Punkt' => ['messe.2026', true], + 'zwei Abschnitte' => ['g/1AEJ0X90ZmF', true], + 'mehrere Abschnitte' => ['a/b/c/d', true], + 'Abschnitte mit Bindestrich' => ['flyer-2026/halle-3', true], + 'maximale Abschnittslaenge' => [str_repeat('a', 64), true], + + // --- unzulaessig ---------------------------------------- 'leer' => ['', false], - 'Grossbuchstaben' => ['Messe2026', false], 'Umlaut' => ['grüße', false], 'Leerzeichen' => ['messe 2026', false], - 'Unterstrich' => ['messe_2026', false], - 'Punkt' => ['messe.2026', false], - 'Slash' => ['messe/2026', false], 'fuehrender Bindestrich' => ['-messe', false], 'abschliessender Bindestrich' => ['messe-', false], - 'doppelter Bindestrich' => ['messe--2026', false], + 'fuehrender Schraegstrich' => ['/messe', false], + 'abschliessender Schraegstrich' => ['messe/', false], + 'leerer Abschnitt' => ['g//abc', false], + 'Abschnitt mit fuehrendem Punkt' => ['g/.abc', false], + 'Punkt-Abschnitt' => ['g/./abc', false], + 'Aufwaerts-Abschnitt' => ['g/../abc', false], 'reserviert admin' => ['admin', false], + 'reserviert als erster Abschnitt' => ['admin/geheim', false], 'reserviert login' => ['login', false], - 'zu lang' => [str_repeat('a', 65), false], - 'maximale Laenge' => [str_repeat('a', 64), true], + 'reserviert well-known' => ['.well-known/x', false], + 'Abschnitt zu lang' => [str_repeat('a', 65), false], + 'zu viele Abschnitte' => ['a/b/c/d/e/f/g/h/i', false], + 'insgesamt zu lang' => [str_repeat('ab/', 70) . 'x', false], ]; } @@ -123,16 +138,52 @@ final class SlugTest extends TestCase public function testNormalizeCreatesUsableSlugs(): void { - self::assertSame('messe-2026', Slug::normalize('Messe 2026')); - self::assertSame('gruesse-aus-muenchen', Slug::normalize('Grüße aus München')); + self::assertSame('Messe-2026', Slug::normalize('Messe 2026')); + self::assertSame('Gruesse-aus-Muenchen', Slug::normalize('Grüße aus München')); self::assertSame('a-b', Slug::normalize('--a---b--')); - self::assertSame('strasse', Slug::normalize('Straße')); + // "ß" wird zu "ss", das fuehrende "S" bleibt gross. + self::assertSame('Strasse', Slug::normalize('Straße')); + self::assertSame('strasse', Slug::normalize('straße')); self::assertSame('', Slug::normalize(' ')); } + /** + * Gross- und Kleinschreibung muss erhalten bleiben: bereits + * gedruckte QR-Codes lassen sich nicht mehr aendern. + */ + public function testNormalizeKeepsLetterCase(): void + { + self::assertSame('1AEJ0X90ZmF', Slug::normalize('1AEJ0X90ZmF')); + self::assertSame('g/1AEJ0X90ZmF', Slug::normalize('g/1AEJ0X90ZmF')); + self::assertSame('MesseFlyer', Slug::normalize('MesseFlyer')); + } + + public function testNormalizeKeepsPathSegments(): void + { + self::assertSame('g/1AEJ0X90ZmF', Slug::normalize('g/1AEJ0X90ZmF')); + self::assertSame('g/1AEJ0X90ZmF', Slug::normalize('/g/1AEJ0X90ZmF')); + self::assertSame('g/1AEJ0X90ZmF', Slug::normalize('g//1AEJ0X90ZmF/')); + self::assertSame('a/b/c', Slug::normalize('a / b / c')); + } + + /** + * Wer die komplette alte Adresse einfuegt, soll nicht scheitern. + */ + public function testNormalizeAcceptsAFullUrl(): void + { + self::assertSame( + 'g/1AEJ0X90ZmF', + Slug::normalize('https://qr.beispiel.de/g/1AEJ0X90ZmF'), + ); + self::assertSame('messe', Slug::normalize('http://beispiel.de/messe')); + } + public function testNormalizedInputIsAlwaysValidOrEmpty(): void { - foreach (['Messe 2026', 'Grüße!', 'a/b/c', '###', 'Test_Fall'] as $input) { + foreach ([ + 'Messe 2026', 'Grüße!', 'a/b/c', '###', 'Test_Fall', + '/g/1AEJ0X90ZmF', 'g//x//y', '---', '...', 'https://x.de/a/b', + ] as $input) { $normalized = Slug::normalize($input); if ($normalized !== '') { @@ -140,4 +191,13 @@ final class SlugTest extends TestCase } } } + + public function testReservedFirstSegmentIsDetected(): void + { + self::assertTrue(Slug::isReserved('admin')); + self::assertTrue(Slug::isReserved('admin/links')); + self::assertTrue(Slug::isReserved('ADMIN/links')); + self::assertFalse(Slug::isReserved('administration')); + self::assertFalse(Slug::isReserved('g/admin')); + } }