feat(links): Mehrteilige Slugs und erhaltene Gross-/Kleinschreibung

Wer bereits gedruckte QR-Codes aus einem anderen System uebernimmt,
kann deren Adressen nicht mehr aendern. Sehen die aus wie
"/g/1AEJ0X90ZmF", waren sie hier bisher nicht abbildbar: der
Normalisierer machte daraus "g-1aej0x90zmf" - Schraegstrich zu
Bindestrich, alles klein. Der Kurzlink zeigte damit ins Leere.

Ein Slug darf jetzt mehrere durch "/" getrennte Abschnitte haben
(hoechstens acht, je 64 Zeichen) und behaelt die eingegebene
Schreibweise. Erlaubt sind Buchstaben, Ziffern, Bindestrich,
Unterstrich und Punkt; jeder Abschnitt muss mit einem Buchstaben oder
einer Ziffer beginnen und enden. Umlaute und Leerzeichen werden
weiterhin umgeschrieben. Wird versehentlich eine komplette URL
eingefuegt, bleibt davon nur der Pfad uebrig.

Damit die Verwaltung nicht verdeckt werden kann, greift die Liste
reservierter Namen jetzt auf den ERSTEN Abschnitt: "admin/geheim" wird
ebenso abgelehnt wie "admin". Die Route der Weiterleitung steht
weiterhin ganz am Ende, alle Verwaltungsrouten gewinnen davor.

Bei der Eindeutigkeit gilt bewusst das Gegenteil: sie wird OHNE
Ruecksicht auf Gross- und Kleinschreibung geprueft. "Messe" und "messe"
koennen auf derselben Domain nicht nebeneinander bestehen - an einer
gedruckten Adresse waere sonst nicht mehr erkennbar, welcher Link
gemeint ist. Ausserdem wuerde MySQL sie je nach Kollation ohnehin als
gleich ansehen, SQLite nicht; so verhalten sich beide Treiber wieder
gleich.

Beim Aufruf wird zuerst exakt gesucht und nur bei einem Fehlschlag ohne
Ruecksicht auf die Schreibweise - abgetippte Codes stimmen nicht immer.
Weil die Eindeutigkeit oben schon ohne Ruecksicht geprueft wurde, kann
dieser zweite Versuch nie mehrdeutig sein.

Geprueft in beiden Betriebsarten: im Container und in der
Shared-Hosting-Variante, bei der die Root-.htaccess nach public/
umleitet - dort sind mehrteilige Pfade der wahrscheinlichste
Stolperstein.
This commit is contained in:
2026-08-12 18:32:41 +02:00
parent 649f6ba247
commit 583d9f0ae2
9 changed files with 366 additions and 61 deletions
+89
View File
@@ -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');
+71 -11
View File
@@ -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'));
}
}