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
+27 -3
View File
@@ -34,6 +34,8 @@ Benutzername und Passwort.
- Lange URL eintragen → Kurzlink erzeugen - Lange URL eintragen → Kurzlink erzeugen
- Zufälliger Slug (kollisionssicher, 7 Zeichen, wächst bei Kollisionen automatisch) - Zufälliger Slug (kollisionssicher, 7 Zeichen, wächst bei Kollisionen automatisch)
oder eigener Wunsch-Slug wie `deine-domain.de/messe2026` 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 - URL-Validierung: ausschließlich `http` und `https` – innerhalb davon aber großzügig
(Umlaut-Domains, Umlaute in Pfad/Query, IPv6, interne Hostnamen ohne Punkt) (Umlaut-Domains, Umlaute in Pfad/Query, IPv6, interne Hostnamen ohne Punkt)
- Weiterleitung per HTTP 301/302/307/308 (konfigurierbar) - 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 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 RFC 7230 ASCII sein müssen. Pfad und Query werden beim Speichern prozentkodiert, ohne
bereits kodierte Sequenzen ein zweites Mal zu kodieren. bereits kodierte Sequenzen ein zweites Mal zu kodieren.
- **Wunsch-Slug** – optional. Leer lassen für einen zufälligen Slug. Eingaben werden - **Wunsch-Slug** – optional. Leer lassen für einen zufälligen Slug.
normalisiert (Großbuchstaben und Umlaute werden umgesetzt, Sonderzeichen zu
Bindestrichen) und auf Eindeutigkeit geprüft. 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. - **Interne Bezeichnung** – nur zur eigenen Orientierung, nirgends öffentlich.
- **Aktiv** – deaktivierte Links liefern die 404-Seite, behalten aber ihre Statistik. - **Aktiv** – deaktivierte Links liefern die 404-Seite, behalten aber ihre Statistik.
+4 -1
View File
@@ -212,7 +212,10 @@ final class App
$router->get(DomainChecker::PATH, [$domainController, 'verify']); $router->get(DomainChecker::PATH, [$domainController, 'verify']);
// --- Oeffentlich: ausschliesslich die Weiterleitung ------------- // --- 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']); $router->fallback([$redirect, 'notFound']);
return $router; return $router;
+3 -3
View File
@@ -49,9 +49,9 @@ final class RedirectController extends Controller
); );
$domainId = (int) $domain['id']; $domainId = (int) $domain['id'];
// Bequemlichkeit: /abc/ und /ABC finden denselben Link. // findBySlug sucht zuerst exakt und faellt dann auf einen
$link = $this->links->findBySlug($slug, $domainId) // Vergleich ohne Ruecksicht auf Gross-/Kleinschreibung zurueck.
?? $this->links->findBySlug(strtolower($slug), $domainId); $link = $this->links->findBySlug($slug, $domainId);
if ($link === null || (int) $link['is_active'] !== 1) { if ($link === null || (int) $link['is_active'] !== 1) {
return $this->notFound($request, ['slug' => $slug]); return $this->notFound($request, ['slug' => $slug]);
+8 -2
View File
@@ -31,15 +31,21 @@ final class Router
$this->fallback = $handler; $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 private function add(string $method, string $pattern, callable $handler): void
{ {
$params = []; $params = [];
$regex = preg_replace_callback( $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 { static function (array $matches) use (&$params): string {
$params[] = $matches[1]; $params[] = $matches[1];
return '([^/]+)'; return ($matches[2] ?? '') === ':path' ? '(.+)' : '([^/]+)';
}, },
$pattern, $pattern,
) ?? $pattern; ) ?? $pattern;
+37 -5
View File
@@ -21,16 +21,39 @@ final class LinkRepository
/** /**
* Ein Slug ist nur innerhalb seiner Domain eindeutig. * 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<string,mixed>|null * @return array<string,mixed>|null
*/ */
public function findBySlug(string $slug, int $domainId): ?array public function findBySlug(string $slug, int $domainId): ?array
{ {
return $this->db->fetchOne( $link = $this->db->fetchOne(
'SELECT l.*, ' . self::DOMAIN_COLUMNS . ' 'SELECT l.*, ' . self::DOMAIN_COLUMNS . '
FROM links l LEFT JOIN domains d ON d.id = l.domain_id FROM links l LEFT JOIN domains d ON d.id = l.domain_id
WHERE l.slug = ? AND l.domain_id = ?', WHERE l.slug = ? AND l.domain_id = ?',
[$slug, $domainId], [$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 public function slugExists(string $slug, int $domainId, ?int $exceptId = null): bool
{ {
$lower = mb_strtolower($slug, 'UTF-8');
if ($exceptId === null) { if ($exceptId === null) {
return $this->db->fetchValue( return $this->db->fetchValue(
'SELECT 1 FROM links WHERE slug = ? AND domain_id = ?', 'SELECT 1 FROM links WHERE LOWER(slug) = ? AND domain_id = ?',
[$slug, $domainId], [$lower, $domainId],
) !== null; ) !== null;
} }
return $this->db->fetchValue( return $this->db->fetchValue(
'SELECT 1 FROM links WHERE slug = ? AND domain_id = ? AND id <> ?', 'SELECT 1 FROM links WHERE LOWER(slug) = ? AND domain_id = ? AND id <> ?',
[$slug, $domainId, $exceptId], [$lower, $domainId, $exceptId],
) !== null; ) !== null;
} }
+117 -32
View File
@@ -5,32 +5,52 @@ declare(strict_types=1);
namespace App\Support; namespace App\Support;
use InvalidArgumentException; use InvalidArgumentException;
use RuntimeException;
/** /**
* Erzeugung und Validierung von Slugs. * 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 final class Slug
{ {
public const MIN_LENGTH = 1; 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 * Kleinbuchstaben plus Ziffern 2-9. Die Ziffern 0 und 1 fehlen, weil
* sie in gedruckten Kurzlinks mit "o" und "l" verwechselt werden - * sie in gedruckten Kurzlinks mit "o" und "l" verwechselt werden -
* da Slugs immer klein geschrieben sind, bleiben die Buchstaben * da erzeugte Slugs immer klein geschrieben sind, bleiben die
* dadurch eindeutig. Ergibt 34 Zeichen, also rund 5,2e10 Kombinationen * Buchstaben dadurch eindeutig. Ergibt 34 Zeichen, also rund 5,2e10
* bei sieben Stellen. * Kombinationen bei sieben Stellen.
*/ */
private const ALPHABET = 'abcdefghijklmnopqrstuvwxyz23456789'; 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<string> * @var list<string>
*/ */
public const RESERVED = [ public const RESERVED = [
'admin', 'login', 'logout', 'setup', 'assets', 'uploads', 'admin', 'login', 'logout', 'setup', 'assets', 'uploads',
'api', 'favicon.ico', 'robots.txt', 'health', 'index', 'api', 'favicon.ico', 'robots.txt', 'health', 'index',
'.well-known',
]; ];
/** /**
@@ -38,7 +58,7 @@ final class Slug
*/ */
public static function random(int $length = 7): string 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.'); throw new InvalidArgumentException('Slug-Laenge muss zwischen 4 und 64 liegen.');
} }
@@ -63,49 +83,65 @@ final class Slug
{ {
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
// Alle 4 Fehlversuche ein Zeichen mehr. // 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; 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, * Bereitet eine Eingabe auf, ohne ihre Aussage zu veraendern:
* alle uebrigen Zeichen zu Bindestrichen. * 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 public static function normalize(string $input): string
{ {
$input = trim($input); $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, [ $input = strtr($input, [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss', 'ä' => '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, * Erlaubt sind Buchstaben, Ziffern, Bindestrich, Unterstrich und
* keine doppelten Bindestriche, 1-64 Zeichen. * Punkt; mehrere Abschnitte werden durch "/" getrennt.
*/ */
public static function isValid(string $slug): bool public static function isValid(string $slug): bool
{ {
if ($slug === '' || strlen($slug) > self::MAX_LENGTH) { return self::validationError($slug) === null;
return false;
}
if (in_array($slug, self::RESERVED, true)) {
return false;
}
return (bool) preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug);
} }
/** /**
@@ -121,18 +157,67 @@ final class Slug
return sprintf('Der Slug darf hoechstens %d Zeichen lang sein.', self::MAX_LENGTH); 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.'; return 'Dieser Slug ist reserviert und kann nicht verwendet werden.';
} }
if (!preg_match('/^[a-z0-9-]+$/', $slug)) { foreach ($segments as $segment) {
return 'Erlaubt sind nur Kleinbuchstaben (a-z), Ziffern (0-9) und Bindestriche.'; if ($segment === '') {
return 'Der Slug darf keine leeren Abschnitte enthalten (kein "//").';
} }
if (!self::isValid($slug)) { if (strlen($segment) > self::MAX_SEGMENT_LENGTH) {
return 'Der Slug darf nicht mit einem Bindestrich beginnen oder enden und keine doppelten Bindestriche enthalten.'; 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; 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);
}
} }
+9 -3
View File
@@ -81,11 +81,17 @@ foreach ($domains as $domain) {
<div class="input-prefix"> <div class="input-prefix">
<span class="input-prefix__label mono" data-slug-prefix><?= $e(rtrim($selectedBase, '/')) ?>/</span> <span class="input-prefix__label mono" data-slug-prefix><?= $e(rtrim($selectedBase, '/')) ?>/</span>
<input type="text" id="slug" name="slug" value="<?= $e($values['slug']) ?>" <input type="text" id="slug" name="slug" value="<?= $e($values['slug']) ?>"
placeholder="<?= $isEdit ? '' : 'z. B. messe2026 (leer = zufällig)' ?>" placeholder="<?= $isEdit ? '' : 'z. B. messe2026 oder g/1AEJ0X90ZmF (leer = zufällig)' ?>"
pattern="[a-z0-9-]*" spellcheck="false" autocapitalize="off" autocomplete="off" pattern="[A-Za-z0-9._~/-]*" spellcheck="false" autocapitalize="off" autocomplete="off"
<?= $isEdit ? 'required' : '' ?>> <?= $isEdit ? 'required' : '' ?>>
</div> </div>
<p class="field__hint">Erlaubt sind Kleinbuchstaben, Ziffern und Bindestriche.</p> <p class="field__hint">
Buchstaben, Ziffern, Bindestrich, Unterstrich und Punkt. Der Schrägstrich trennt
Abschnitte, sodass sich auch Adressen wie <code>g/1AEJ0X90ZmF</code> abbilden lassen –
praktisch, um bereits gedruckte QR-Codes aus einem anderen System zu übernehmen.
<strong>Groß- und Kleinschreibung bleibt erhalten</strong> und muss beim Anlegen
exakt stimmen; beim Aufruf wird sie zur Sicherheit auch ohne Beachtung erkannt.
</p>
<?php if (isset($errors['slug'])): ?> <?php if (isset($errors['slug'])): ?>
<p class="field__error"><?= $e($errors['slug']) ?></p> <p class="field__error"><?= $e($errors['slug']) ?></p>
<?php endif; ?> <?php endif; ?>
+89
View File
@@ -326,6 +326,95 @@ final class RedirectTest extends TestCase
self::assertSame(302, $response->status()); 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 public function testUmlautTargetIsRedirectedAsPunycode(): void
{ {
$this->links->create($this->defaultDomainId, 'umlaut', 'https://müller.de/preise'); $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 public static function slugValidationProvider(): array
{ {
return [ return [
// --- zulaessig ------------------------------------------
'einfach' => ['messe2026', true], 'einfach' => ['messe2026', true],
'mit Bindestrich' => ['messe-2026-halle-3', true], 'mit Bindestrich' => ['messe-2026-halle-3', true],
'nur Ziffern' => ['12345', true], 'nur Ziffern' => ['12345', true],
'einzelnes Zeichen' => ['a', 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], 'leer' => ['', false],
'Grossbuchstaben' => ['Messe2026', false],
'Umlaut' => ['grüße', false], 'Umlaut' => ['grüße', false],
'Leerzeichen' => ['messe 2026', false], 'Leerzeichen' => ['messe 2026', false],
'Unterstrich' => ['messe_2026', false],
'Punkt' => ['messe.2026', false],
'Slash' => ['messe/2026', false],
'fuehrender Bindestrich' => ['-messe', false], 'fuehrender Bindestrich' => ['-messe', false],
'abschliessender 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 admin' => ['admin', false],
'reserviert als erster Abschnitt' => ['admin/geheim', false],
'reserviert login' => ['login', false], 'reserviert login' => ['login', false],
'zu lang' => [str_repeat('a', 65), false], 'reserviert well-known' => ['.well-known/x', false],
'maximale Laenge' => [str_repeat('a', 64), true], '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 public function testNormalizeCreatesUsableSlugs(): void
{ {
self::assertSame('messe-2026', Slug::normalize('Messe 2026')); self::assertSame('Messe-2026', Slug::normalize('Messe 2026'));
self::assertSame('gruesse-aus-muenchen', Slug::normalize('Grüße aus München')); self::assertSame('Gruesse-aus-Muenchen', Slug::normalize('Grüße aus München'));
self::assertSame('a-b', Slug::normalize('--a---b--')); 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(' ')); 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 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); $normalized = Slug::normalize($input);
if ($normalized !== '') { 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'));
}
} }