feat(links): Kurzlinks, Weiterleitung und Klickstatistik

Slugs aus 34 Zeichen (0 und 1 fehlen wegen Verwechslung mit o und l),
kollisionssicher mit wachsender Laenge. Wunsch-Slugs werden normalisiert
und gegen eine Liste reservierter Pfade geprueft.

Die URL-Validierung kommt bewusst ohne FILTER_VALIDATE_URL aus: dieses
verwirft jedes Nicht-ASCII-Zeichen und lehnt damit Umlaut-Domains und
Umlaute im Pfad ab. Erlaubt sind nur http und https, darin aber auch
IDN, IPv6 und interne Hostnamen ohne Punkt. Gespeichert wird die
lesbare Schreibweise; Punycode entsteht erst im Location-Header, weil
HTTP-Header laut RFC 7230 ASCII sein muessen.

Die Statistik speichert keine IP-Adressen (optional nur einen taeglich
rotierenden Hash) und laeuft nach dem Ausliefern der Weiterleitung.
This commit is contained in:
2026-08-03 18:11:39 +02:00
parent e3d8e70a38
commit f48f7916b3
14 changed files with 2265 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Punycode-Kodierung fuer internationalisierte Domainnamen (RFC 3492).
*
* Wird nur gebraucht, um beim Weiterleiten einen ASCII-reinen
* Location-Header zu erzeugen - HTTP-Header duerfen laut RFC 7230 kein
* UTF-8 enthalten. Gespeichert und angezeigt wird weiterhin die
* lesbare Schreibweise ("müller.de" statt "xn--mller-kva.de").
*
* Ist ext-intl vorhanden, wird idn_to_ascii() bevorzugt: das beherrscht
* zusaetzlich die vollstaendige UTS-46-Normalisierung. Die eigene
* Implementierung ist der Rueckfallweg fuer Hosting ohne intl.
*/
final class Punycode
{
private const BASE = 36;
private const TMIN = 1;
private const TMAX = 26;
private const SKEW = 38;
private const DAMP = 700;
private const INITIAL_BIAS = 72;
private const INITIAL_N = 128;
private const DELIMITER = '-';
private const PREFIX = 'xn--';
/**
* Wandelt einen Hostnamen label-weise in seine ASCII-Form.
* Reine ASCII-Hosts werden unveraendert zurueckgegeben.
*/
public static function encodeDomain(string $host): string
{
if ($host === '' || self::isAscii($host)) {
return $host;
}
if (function_exists('idn_to_ascii')) {
$converted = @idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
if (is_string($converted) && $converted !== '') {
return $converted;
}
}
$labels = explode('.', $host);
foreach ($labels as $index => $label) {
$labels[$index] = self::encodeLabel($label);
}
return implode('.', $labels);
}
public static function isAscii(string $value): bool
{
return preg_match('/[^\x00-\x7F]/', $value) !== 1;
}
private static function encodeLabel(string $label): string
{
if ($label === '' || self::isAscii($label)) {
return $label;
}
$encoded = self::encode(self::codePoints($label));
return $encoded === '' ? $label : self::PREFIX . $encoded;
}
/**
* @return list<int>
*/
private static function codePoints(string $value): array
{
$points = [];
foreach (mb_str_split($value, 1, 'UTF-8') as $character) {
$codePoint = mb_ord($character, 'UTF-8');
if ($codePoint === false) {
continue;
}
$points[] = $codePoint;
}
return $points;
}
/**
* Kern des Verfahrens nach RFC 3492, Abschnitt 6.3.
*
* @param list<int> $input
*/
private static function encode(array $input): string
{
$n = self::INITIAL_N;
$delta = 0;
$bias = self::INITIAL_BIAS;
// Alle direkt darstellbaren (ASCII-) Zeichen zuerst ausgeben.
$output = '';
foreach ($input as $codePoint) {
if ($codePoint < 128) {
$output .= chr($codePoint);
}
}
$basicCount = strlen($output);
$handled = $basicCount;
if ($basicCount > 0) {
$output .= self::DELIMITER;
}
$total = count($input);
while ($handled < $total) {
// Kleinster noch nicht verarbeiteter Codepoint.
$m = PHP_INT_MAX;
foreach ($input as $codePoint) {
if ($codePoint >= $n && $codePoint < $m) {
$m = $codePoint;
}
}
$delta += ($m - $n) * ($handled + 1);
$n = $m;
foreach ($input as $codePoint) {
if ($codePoint < $n) {
$delta++;
}
if ($codePoint !== $n) {
continue;
}
$q = $delta;
for ($k = self::BASE; ; $k += self::BASE) {
$t = self::threshold($k, $bias);
if ($q < $t) {
break;
}
$output .= self::digit($t + (($q - $t) % (self::BASE - $t)));
$q = intdiv($q - $t, self::BASE - $t);
}
$output .= self::digit($q);
$bias = self::adapt($delta, $handled + 1, $handled === $basicCount);
$delta = 0;
$handled++;
}
$delta++;
$n++;
}
return $output;
}
private static function threshold(int $k, int $bias): int
{
if ($k <= $bias + self::TMIN) {
return self::TMIN;
}
if ($k >= $bias + self::TMAX) {
return self::TMAX;
}
return $k - $bias;
}
private static function adapt(int $delta, int $numPoints, bool $firstTime): int
{
$delta = $firstTime ? intdiv($delta, self::DAMP) : intdiv($delta, 2);
$delta += intdiv($delta, $numPoints);
$k = 0;
while ($delta > intdiv((self::BASE - self::TMIN) * self::TMAX, 2)) {
$delta = intdiv($delta, self::BASE - self::TMIN);
$k += self::BASE;
}
return $k + intdiv((self::BASE - self::TMIN + 1) * $delta, $delta + self::SKEW);
}
/**
* 0-25 -> a-z, 26-35 -> 0-9
*/
private static function digit(int $value): string
{
return chr($value + ($value < 26 ? 97 : 22));
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Support;
use InvalidArgumentException;
/**
* Erzeugung und Validierung von Slugs.
*/
final class Slug
{
public const MIN_LENGTH = 1;
public const MAX_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.
*/
private const ALPHABET = 'abcdefghijklmnopqrstuvwxyz23456789';
/**
* Reservierte Pfade, die nicht als Slug vergeben werden duerfen.
*
* @var list<string>
*/
public const RESERVED = [
'admin', 'login', 'logout', 'setup', 'assets', 'uploads',
'api', 'favicon.ico', 'robots.txt', 'health', 'index',
];
/**
* Zufaelligen Slug erzeugen (kryptografisch sicherer Zufall).
*/
public static function random(int $length = 7): string
{
if ($length < 4 || $length > self::MAX_LENGTH) {
throw new InvalidArgumentException('Slug-Laenge muss zwischen 4 und 64 liegen.');
}
$alphabet = self::ALPHABET;
$max = strlen($alphabet) - 1;
$slug = '';
for ($i = 0; $i < $length; $i++) {
$slug .= $alphabet[random_int(0, $max)];
}
return $slug;
}
/**
* Erzeugt einen zufaelligen Slug, der laut $exists-Pruefung noch frei ist.
* Bei anhaltenden Kollisionen waechst die Laenge automatisch.
*
* @param callable(string):bool $exists
*/
public static function randomUnique(callable $exists, int $length = 7, int $maxAttempts = 12): string
{
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
// Alle 4 Fehlversuche ein Zeichen mehr.
$candidate = self::random(min($length + intdiv($attempt, 4), self::MAX_LENGTH));
if (!in_array($candidate, self::RESERVED, true) && !$exists($candidate)) {
return $candidate;
}
}
throw new \RuntimeException('Es konnte kein freier Slug erzeugt werden.');
}
/**
* Normalisiert einen Wunsch-Slug: Kleinbuchstaben, Umlaute ersetzt,
* alle uebrigen Zeichen zu Bindestrichen.
*/
public static function normalize(string $input): string
{
$input = trim($input);
$input = strtr($input, [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss',
'Ä' => 'ae', 'Ö' => 'oe', 'Ü' => 'ue',
]);
$input = strtolower($input);
$input = preg_replace('/[^a-z0-9-]+/', '-', $input) ?? '';
$input = preg_replace('/-{2,}/', '-', $input) ?? '';
return trim($input, '-');
}
/**
* Erlaubt: a-z, 0-9 und Bindestrich; nicht am Anfang/Ende,
* keine doppelten Bindestriche, 1-64 Zeichen.
*/
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);
}
/**
* Liefert eine Fehlermeldung oder null, wenn der Slug gueltig ist.
*/
public static function validationError(string $slug): ?string
{
if ($slug === '') {
return 'Der Slug darf nicht leer sein.';
}
if (strlen($slug) > self::MAX_LENGTH) {
return sprintf('Der Slug darf hoechstens %d Zeichen lang sein.', self::MAX_LENGTH);
}
if (in_array($slug, self::RESERVED, true)) {
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.';
}
if (!self::isValid($slug)) {
return 'Der Slug darf nicht mit einem Bindestrich beginnen oder enden und keine doppelten Bindestriche enthalten.';
}
return null;
}
}
+276
View File
@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Validierung und Normalisierung der Ziel-URLs.
*
* Erlaubt sind ausschliesslich http und https. Innerhalb dieser Schemata
* ist die Pruefung bewusst grosszuegig: internationalisierte Domains
* ("müller.de"), Umlaute in Pfad und Query, IPv6-Adressen und interne
* Hostnamen ohne Punkt ("intranet", "nas:5000") sind zulaessig.
*
* FILTER_VALIDATE_URL wird absichtlich NICHT verwendet - es verwirft
* jedes Nicht-ASCII-Zeichen und lehnt damit genau die Faelle ab, die in
* der Praxis am haeufigsten vorkommen.
*
* Gespeichert wird die lesbare Schreibweise. Erst beim Erzeugen des
* Location-Headers wird der Host per Punycode nach ASCII gewandelt.
*/
final class UrlValidator
{
public const MAX_LENGTH = 2048;
/** Maximale Laenge des Hostnamens nach RFC 1035. */
private const MAX_HOST_LENGTH = 253;
/**
* Bereitet eine Benutzereingabe auf:
* - Leerraum entfernen, fehlendes Schema durch https ersetzen
* - Schema und Host in Kleinbuchstaben
* - Pfad, Query und Fragment prozentkodieren (ohne Doppelkodierung)
*
* Der Host bleibt in seiner lesbaren Form erhalten.
*/
public static function normalize(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
// Schema-relative URLs (//example.com) auf https anheben.
if (str_starts_with($url, '//')) {
$url = 'https:' . $url;
} elseif (!preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $url)) {
// Kein Schema angegeben -> https ergaenzen.
$url = 'https://' . $url;
}
$parts = self::split($url);
if ($parts === null) {
return $url;
}
// Fremde Schemata unveraendert lassen; die Validierung lehnt sie ab.
if (!in_array($parts['scheme'], ['http', 'https'], true)) {
return $url;
}
$result = $parts['scheme'] . '://';
if ($parts['userinfo'] !== '') {
$result .= self::encode($parts['userinfo'], ':') . '@';
}
$result .= $parts['host'];
if ($parts['port'] !== '') {
$result .= ':' . $parts['port'];
}
$result .= self::encode($parts['path'], "/:@!$&'()*+,;=");
if ($parts['query'] !== '') {
$result .= '?' . self::encode($parts['query'], "/?:@!$&'()*+,;=");
}
if ($parts['fragment'] !== '') {
$result .= '#' . self::encode($parts['fragment'], "/?:@!$&'()*+,;=");
}
return $result;
}
/**
* Erzeugt die ASCII-Form fuer den Location-Header.
* HTTP-Header duerfen laut RFC 7230 kein UTF-8 enthalten.
*/
public static function toAsciiUrl(string $url): string
{
$parts = self::split($url);
if ($parts === null || $parts['host'] === '' || Punycode::isAscii($parts['host'])) {
return $url;
}
$result = $parts['scheme'] . '://';
if ($parts['userinfo'] !== '') {
$result .= $parts['userinfo'] . '@';
}
$result .= Punycode::encodeDomain($parts['host']);
if ($parts['port'] !== '') {
$result .= ':' . $parts['port'];
}
$result .= $parts['path'];
if ($parts['query'] !== '') {
$result .= '?' . $parts['query'];
}
if ($parts['fragment'] !== '') {
$result .= '#' . $parts['fragment'];
}
return $result;
}
public static function isValid(string $url): bool
{
return self::validationError($url) === null;
}
/**
* Liefert eine Fehlermeldung oder null, wenn die URL gueltig ist.
*/
public static function validationError(string $url): ?string
{
if (trim($url) === '') {
return 'Bitte eine Ziel-URL angeben.';
}
if (strlen($url) > self::MAX_LENGTH) {
return sprintf('Die URL darf hoechstens %d Zeichen lang sein.', self::MAX_LENGTH);
}
if (preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
return 'Die URL enthaelt ungueltige Steuerzeichen.';
}
$parts = self::split($url);
if ($parts === null) {
// Schemata ohne "//" (javascript:, data:, mailto:) klar benennen.
if (preg_match('#^([a-zA-Z][a-zA-Z0-9+.-]*):#', trim($url), $matches) === 1
&& !in_array(strtolower($matches[1]), ['http', 'https'], true)) {
return 'Nur http:// und https:// sind als Ziel erlaubt.';
}
return 'Die URL ist syntaktisch ungueltig.';
}
if (!in_array($parts['scheme'], ['http', 'https'], true)) {
return 'Nur http:// und https:// sind als Ziel erlaubt.';
}
if ($parts['host'] === '') {
return 'Die URL enthaelt keinen gueltigen Hostnamen.';
}
if (strlen($parts['host']) > self::MAX_HOST_LENGTH) {
return 'Der Hostname der URL ist zu lang.';
}
if ($parts['port'] !== '') {
$port = (int) $parts['port'];
if ($port < 1 || $port > 65535) {
return 'Die Portangabe der URL ist ungueltig.';
}
}
if (!self::isValidHost($parts['host'])) {
return 'Der Hostname der URL sieht nicht gueltig aus.';
}
return null;
}
/**
* Zerlegt eine URL, ohne an Nicht-ASCII-Zeichen zu scheitern.
*
* @return array{scheme:string,userinfo:string,host:string,port:string,path:string,query:string,fragment:string}|null
*/
private static function split(string $url): ?array
{
$pattern = '#^'
. '(?<scheme>[a-zA-Z][a-zA-Z0-9+.-]*)://'
. '(?:(?<userinfo>[^/?\#@]*)@)?'
. '(?<host>\[[0-9A-Fa-f:.]+\]|[^/?\#:]*)'
. '(?::(?<port>[0-9]*))?'
. '(?<path>/[^?\#]*)?'
. '(?:\?(?<query>[^\#]*))?'
. '(?:\#(?<fragment>.*))?'
. '$#';
if (preg_match($pattern, $url, $matches) !== 1) {
return null;
}
return [
'scheme' => strtolower($matches['scheme']),
'userinfo' => $matches['userinfo'] ?? '',
'host' => mb_strtolower($matches['host'], 'UTF-8'),
'port' => $matches['port'] ?? '',
'path' => $matches['path'] ?? '',
'query' => $matches['query'] ?? '',
'fragment' => $matches['fragment'] ?? '',
];
}
/**
* Erlaubt IPv4, IPv6 in eckigen Klammern sowie Hostnamen - auch
* einteilige (Intranet) und internationalisierte.
*/
private static function isValidHost(string $host): bool
{
// IPv6-Literal, z. B. [2001:db8::1]
if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
return filter_var(substr($host, 1, -1), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
}
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return true;
}
// Abschliessender Punkt (absolute Domain) ist zulaessig.
$host = rtrim($host, '.');
if ($host === '') {
return false;
}
foreach (explode('.', $host) as $label) {
if ($label === '' || strlen($label) > 63) {
return false;
}
// Buchstaben (inkl. Unicode), Ziffern und Bindestriche;
// Bindestrich nicht am Anfang oder Ende.
if (preg_match('/^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u', $label) !== 1) {
return false;
}
}
return true;
}
/**
* Prozentkodiert alles ausser den erlaubten Zeichen. Bereits
* kodierte Sequenzen (%XX) bleiben unveraendert, damit nicht
* doppelt kodiert wird.
*/
private static function encode(string $value, string $allowed): string
{
if ($value === '') {
return '';
}
$pattern = '/%[0-9A-Fa-f]{2}|[^A-Za-z0-9\-._~' . preg_quote($allowed, '/') . ']/';
return preg_replace_callback(
$pattern,
static function (array $matches): string {
// Schon kodiert: nur die Schreibweise vereinheitlichen.
if ($matches[0][0] === '%') {
return strtoupper($matches[0]);
}
return rawurlencode($matches[0]);
},
$value,
) ?? $value;
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Sehr leichtgewichtige User-Agent-Auswertung.
* Es werden bewusst nur Geraetetyp, Browser und Plattform abgeleitet -
* kein Fingerprinting, keine externe Bibliothek.
*/
final class UserAgentParser
{
public const DEVICE_DESKTOP = 'desktop';
public const DEVICE_MOBILE = 'mobile';
public const DEVICE_TABLET = 'tablet';
public const DEVICE_BOT = 'bot';
public const UNKNOWN = 'unknown';
/**
* @return array{device:string,browser:string,platform:string}
*/
public static function parse(string $userAgent): array
{
$ua = trim($userAgent);
if ($ua === '') {
return ['device' => self::UNKNOWN, 'browser' => self::UNKNOWN, 'platform' => self::UNKNOWN];
}
return [
'device' => self::device($ua),
'browser' => self::browser($ua),
'platform' => self::platform($ua),
];
}
public static function device(string $ua): string
{
$lower = strtolower($ua);
if (preg_match('/bot|crawler|spider|crawling|slurp|facebookexternalhit|preview|monitor|curl|wget|python-requests|headless/i', $lower) === 1) {
return self::DEVICE_BOT;
}
if (preg_match('/ipad|tablet|playbook|silk|kindle/i', $lower) === 1) {
return self::DEVICE_TABLET;
}
// Android ohne "Mobile" ist per Konvention ein Tablet.
if (str_contains($lower, 'android') && !str_contains($lower, 'mobile')) {
return self::DEVICE_TABLET;
}
if (preg_match('/mobile|iphone|ipod|android|blackberry|windows phone|opera mini|iemobile/i', $lower) === 1) {
return self::DEVICE_MOBILE;
}
return self::DEVICE_DESKTOP;
}
public static function browser(string $ua): string
{
// Reihenfolge ist wichtig: spezielle Browser vor den generischen.
$patterns = [
'Edge' => '/edg(?:e|a|ios)?\//i',
'Opera' => '/opr\/|opera/i',
'Samsung Internet' => '/samsungbrowser/i',
'Vivaldi' => '/vivaldi/i',
'Brave' => '/brave/i',
'Chrome' => '/chrome|crios|chromium/i',
'Firefox' => '/firefox|fxios/i',
'Safari' => '/safari/i',
'Internet Explorer' => '/msie|trident/i',
];
foreach ($patterns as $name => $pattern) {
if (preg_match($pattern, $ua) === 1) {
return $name;
}
}
if (self::device($ua) === self::DEVICE_BOT) {
return 'Bot';
}
return self::UNKNOWN;
}
public static function platform(string $ua): string
{
$patterns = [
'Android' => '/android/i',
'iOS' => '/iphone|ipad|ipod|ios/i',
'Windows' => '/windows nt|win64|win32/i',
'macOS' => '/mac os x|macintosh/i',
'Linux' => '/linux|x11/i',
];
foreach ($patterns as $name => $pattern) {
if (preg_match($pattern, $ua) === 1) {
return $name;
}
}
return self::UNKNOWN;
}
public static function deviceLabel(string $device): string
{
return match ($device) {
self::DEVICE_DESKTOP => 'Desktop',
self::DEVICE_MOBILE => 'Mobil',
self::DEVICE_TABLET => 'Tablet',
self::DEVICE_BOT => 'Bot',
default => 'Unbekannt',
};
}
}