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:
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+118
-33
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; ?>
|
||||||
|
|||||||
@@ -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
@@ -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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user