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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user