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:
+4
-1
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
+8
-2
@@ -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;
|
||||
|
||||
@@ -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<string,mixed>|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;
|
||||
}
|
||||
|
||||
|
||||
+118
-33
@@ -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<string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user