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