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
|
||||
- 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.
|
||||
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
+117
-32
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,11 +81,17 @@ foreach ($domains as $domain) {
|
||||
<div class="input-prefix">
|
||||
<span class="input-prefix__label mono" data-slug-prefix><?= $e(rtrim($selectedBase, '/')) ?>/</span>
|
||||
<input type="text" id="slug" name="slug" value="<?= $e($values['slug']) ?>"
|
||||
placeholder="<?= $isEdit ? '' : 'z. B. messe2026 (leer = zufällig)' ?>"
|
||||
pattern="[a-z0-9-]*" spellcheck="false" autocapitalize="off" autocomplete="off"
|
||||
placeholder="<?= $isEdit ? '' : 'z. B. messe2026 oder g/1AEJ0X90ZmF (leer = zufällig)' ?>"
|
||||
pattern="[A-Za-z0-9._~/-]*" spellcheck="false" autocapitalize="off" autocomplete="off"
|
||||
<?= $isEdit ? 'required' : '' ?>>
|
||||
</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'])): ?>
|
||||
<p class="field__error"><?= $e($errors['slug']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -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
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user