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,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
|
use App\Http\Request;
|
||||||
|
use App\Http\Response;
|
||||||
|
use App\Security\Auth;
|
||||||
|
use App\Security\Csrf;
|
||||||
|
use App\Security\RateLimiter;
|
||||||
|
use App\Security\Session;
|
||||||
|
use App\View;
|
||||||
|
|
||||||
|
final class AuthController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
Config $config,
|
||||||
|
View $view,
|
||||||
|
private readonly Session $session,
|
||||||
|
private readonly Csrf $csrf,
|
||||||
|
private readonly Auth $auth,
|
||||||
|
private readonly RateLimiter $rateLimiter,
|
||||||
|
) {
|
||||||
|
parent::__construct($config, $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$this->auth->isConfigured()) {
|
||||||
|
return $this->redirect('/setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->auth->check()) {
|
||||||
|
return $this->redirect('/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->html('auth/login', [
|
||||||
|
'username' => '',
|
||||||
|
'error' => null,
|
||||||
|
], 200, 'layout_plain');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticate(Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$this->auth->isConfigured()) {
|
||||||
|
return $this->redirect('/setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ip = $request->clientIp();
|
||||||
|
|
||||||
|
if ($this->rateLimiter->isLocked($ip)) {
|
||||||
|
$seconds = $this->rateLimiter->retryAfter($ip);
|
||||||
|
|
||||||
|
return $this->html('auth/login', [
|
||||||
|
'username' => $request->post('username'),
|
||||||
|
'error' => sprintf(
|
||||||
|
'Zu viele Fehlversuche. Bitte in %d Minuten erneut versuchen.',
|
||||||
|
max(1, (int) ceil($seconds / 60)),
|
||||||
|
),
|
||||||
|
], 429, 'layout_plain')->withHeader('Retry-After', (string) $seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = $request->post('username');
|
||||||
|
$password = (string) ($request->post['password'] ?? '');
|
||||||
|
|
||||||
|
if (!$this->auth->verifyCredentials($username, $password)) {
|
||||||
|
$this->rateLimiter->recordFailure($ip);
|
||||||
|
$remaining = $this->rateLimiter->remainingAttempts($ip);
|
||||||
|
|
||||||
|
// Kleine Verzoegerung gegen automatisiertes Durchprobieren.
|
||||||
|
usleep(300000);
|
||||||
|
|
||||||
|
return $this->html('auth/login', [
|
||||||
|
'username' => $username,
|
||||||
|
'error' => $remaining > 0
|
||||||
|
? sprintf('Benutzername oder Passwort ist falsch. Noch %d Versuch(e).', $remaining)
|
||||||
|
: 'Benutzername oder Passwort ist falsch. Der Zugang ist jetzt vorübergehend gesperrt.',
|
||||||
|
], 401, 'layout_plain');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->rateLimiter->recordSuccess($ip);
|
||||||
|
$this->auth->login($username);
|
||||||
|
|
||||||
|
$intended = $this->session->get('_intended');
|
||||||
|
$this->session->remove('_intended');
|
||||||
|
$target = is_string($intended) && str_starts_with($intended, '/') ? $intended : '/admin';
|
||||||
|
|
||||||
|
$this->session->flash('success', 'Willkommen zurück, ' . $username . '.');
|
||||||
|
|
||||||
|
return $this->redirect($target);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auth->logout();
|
||||||
|
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
|
use App\Http\Request;
|
||||||
|
use App\Http\Response;
|
||||||
|
use App\Security\Auth;
|
||||||
|
use App\Security\Csrf;
|
||||||
|
use App\Security\Session;
|
||||||
|
use App\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Helfer fuer alle Controller.
|
||||||
|
*/
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected readonly Config $config,
|
||||||
|
protected readonly View $view,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basispfad der Anwendung (leer, wenn im Webroot installiert;
|
||||||
|
* sonst z. B. "/kurz").
|
||||||
|
*/
|
||||||
|
protected function basePath(): string
|
||||||
|
{
|
||||||
|
return \App\Http\BasePath::fromGlobals();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function url(string $path): string
|
||||||
|
{
|
||||||
|
return $this->basePath() . '/' . ltrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function redirect(string $path, int $status = 302): Response
|
||||||
|
{
|
||||||
|
return Response::redirect($this->url($path), $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $data
|
||||||
|
*/
|
||||||
|
protected function html(string $template, array $data = [], int $status = 200, string $layout = 'layout'): Response
|
||||||
|
{
|
||||||
|
return Response::html($this->view->render($template, $data, $layout), $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function requireAuth(Auth $auth, Session $session, Request $request): ?Response
|
||||||
|
{
|
||||||
|
if ($auth->check()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$auth->isConfigured()) {
|
||||||
|
return $this->redirect('/setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach dem Login zurueck zur urspruenglich gewuenschten Seite.
|
||||||
|
if ($request->method === 'GET') {
|
||||||
|
$session->set('_intended', $request->path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function requireCsrf(Csrf $csrf, Request $request): ?Response
|
||||||
|
{
|
||||||
|
$token = $request->post[Csrf::FIELD] ?? null;
|
||||||
|
|
||||||
|
if ($csrf->isValid(is_string($token) ? $token : null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 403 statt eines Sonderstatus wie 419: nicht registrierte Codes
|
||||||
|
// ersetzt Apache/mod_php durch einen 500er.
|
||||||
|
return Response::html(
|
||||||
|
$this->view->render(
|
||||||
|
'errors/error',
|
||||||
|
[
|
||||||
|
'title' => 'Sitzung abgelaufen',
|
||||||
|
'message' => 'Das Sicherheitstoken war ungültig oder die Sitzung ist abgelaufen. Bitte die Seite neu laden und den Vorgang wiederholen.',
|
||||||
|
'pageTitle' => 'Sitzung abgelaufen',
|
||||||
|
],
|
||||||
|
'layout_plain',
|
||||||
|
),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
|
use App\Http\Request;
|
||||||
|
use App\Http\Response;
|
||||||
|
use App\Repository\DomainRepository;
|
||||||
|
use App\Repository\LinkRepository;
|
||||||
|
use App\Repository\VisitRepository;
|
||||||
|
use App\Security\Auth;
|
||||||
|
use App\Security\Session;
|
||||||
|
use App\View;
|
||||||
|
|
||||||
|
final class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
View $view,
|
||||||
|
private readonly Auth $auth,
|
||||||
|
private readonly LinkRepository $links,
|
||||||
|
private readonly VisitRepository $visits,
|
||||||
|
Config $config,
|
||||||
|
private readonly DomainRepository $domains,
|
||||||
|
) {
|
||||||
|
parent::__construct($config, $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$this->auth->check()) {
|
||||||
|
if (!$this->auth->isConfigured()) {
|
||||||
|
return $this->redirect('/setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorgt nebenbei dafuer, dass die Standarddomain existiert.
|
||||||
|
$this->domains->defaultDomain($this->config->baseUrl());
|
||||||
|
|
||||||
|
return $this->html('dashboard/index', [
|
||||||
|
'summary' => $this->links->summary(),
|
||||||
|
'recentLinks' => $this->links->all('', 'created_desc', 5),
|
||||||
|
'topLinks' => $this->links->all('', 'clicks_desc', 5),
|
||||||
|
'series' => $this->visits->clicksPerDayGlobal(30),
|
||||||
|
'domainCount' => $this->domains->count(),
|
||||||
|
'pageTitle' => 'Übersicht',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
|
use App\Http\Request;
|
||||||
|
use App\Http\Response;
|
||||||
|
use App\Qr\LogoStore;
|
||||||
|
use App\Repository\DomainRepository;
|
||||||
|
use App\Repository\LinkRepository;
|
||||||
|
use App\Repository\QrDesignRepository;
|
||||||
|
use App\Security\Auth;
|
||||||
|
use App\Security\Csrf;
|
||||||
|
use App\Security\Session;
|
||||||
|
use App\Support\Slug;
|
||||||
|
use App\Support\UrlValidator;
|
||||||
|
use App\View;
|
||||||
|
|
||||||
|
final class LinkController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
Config $config,
|
||||||
|
View $view,
|
||||||
|
private readonly Session $session,
|
||||||
|
private readonly Csrf $csrf,
|
||||||
|
private readonly Auth $auth,
|
||||||
|
private readonly LinkRepository $links,
|
||||||
|
private readonly QrDesignRepository $designs,
|
||||||
|
private readonly LogoStore $logos,
|
||||||
|
private readonly DomainRepository $domains,
|
||||||
|
) {
|
||||||
|
parent::__construct($config, $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$search = $request->query('q');
|
||||||
|
$sort = $request->query('sort', 'created_desc');
|
||||||
|
|
||||||
|
$domains = $this->allDomains();
|
||||||
|
$domainFilter = $request->queryInt('domain', 0);
|
||||||
|
$domainFilter = $this->isKnownDomain($domains, $domainFilter) ? $domainFilter : null;
|
||||||
|
|
||||||
|
return $this->html('links/index', [
|
||||||
|
'links' => $this->links->all($search, $sort, 200, 0, $domainFilter),
|
||||||
|
'total' => $this->links->count($search, $domainFilter),
|
||||||
|
'search' => $search,
|
||||||
|
'sort' => $sort,
|
||||||
|
'domains' => $domains,
|
||||||
|
'domainFilter' => $domainFilter,
|
||||||
|
'pageTitle' => 'Kurzlinks',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$domains = $this->allDomains();
|
||||||
|
$preselected = $request->queryInt('domain', 0);
|
||||||
|
|
||||||
|
if (!$this->isKnownDomain($domains, $preselected)) {
|
||||||
|
$preselected = (int) $this->domains->defaultDomain($this->config->baseUrl())['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->html('links/form', [
|
||||||
|
'link' => null,
|
||||||
|
'domains' => $domains,
|
||||||
|
'values' => [
|
||||||
|
'target_url' => '',
|
||||||
|
'slug' => '',
|
||||||
|
'title' => '',
|
||||||
|
'is_active' => '1',
|
||||||
|
'domain_id' => (string) $preselected,
|
||||||
|
],
|
||||||
|
'errors' => [],
|
||||||
|
'pageTitle' => 'Neuer Kurzlink',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$domains = $this->allDomains();
|
||||||
|
$values = $this->readValues($request);
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// Ohne Angabe gilt die Standarddomain; nur ein ausgefuellter,
|
||||||
|
// aber unbekannter Wert ist ein Fehler.
|
||||||
|
$domainId = (int) $this->domains->defaultDomain($this->config->baseUrl())['id'];
|
||||||
|
if ($values['domain_id'] !== '') {
|
||||||
|
if ($this->isKnownDomain($domains, (int) $values['domain_id'])) {
|
||||||
|
$domainId = (int) $values['domain_id'];
|
||||||
|
} else {
|
||||||
|
$errors['domain_id'] = 'Bitte eine gültige Domain auswählen.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$values['domain_id'] = (string) $domainId;
|
||||||
|
|
||||||
|
$targetUrl = UrlValidator::normalize($values['target_url']);
|
||||||
|
$urlError = UrlValidator::validationError($targetUrl);
|
||||||
|
if ($urlError !== null) {
|
||||||
|
$errors['target_url'] = $urlError;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = $values['slug'];
|
||||||
|
if ($slug === '') {
|
||||||
|
$slug = Slug::randomUnique(
|
||||||
|
fn (string $candidate): bool => $this->links->slugExists($candidate, $domainId),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$slug = Slug::normalize($slug);
|
||||||
|
$slugError = Slug::validationError($slug);
|
||||||
|
|
||||||
|
if ($slugError !== null) {
|
||||||
|
$errors['slug'] = $slugError;
|
||||||
|
} elseif ($this->links->slugExists($slug, $domainId)) {
|
||||||
|
$errors['slug'] = 'Dieser Slug ist auf der gewählten Domain bereits vergeben.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors !== []) {
|
||||||
|
return $this->html('links/form', [
|
||||||
|
'link' => null,
|
||||||
|
'domains' => $domains,
|
||||||
|
'values' => $values,
|
||||||
|
'errors' => $errors,
|
||||||
|
'pageTitle' => 'Neuer Kurzlink',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->links->create(
|
||||||
|
$domainId,
|
||||||
|
$slug,
|
||||||
|
$targetUrl,
|
||||||
|
$values['title'] !== '' ? $values['title'] : null,
|
||||||
|
$values['is_active'] === '1',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->session->flash('success', sprintf(
|
||||||
|
'Kurzlink "%s" wurde angelegt.',
|
||||||
|
LinkRepository::shortUrlOf(
|
||||||
|
['slug' => $slug, 'domain_base_url' => $this->baseUrlOf($domains, $domainId)],
|
||||||
|
$this->config->baseUrl(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $this->redirect('/admin/links/' . $id . '/qr');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function edit(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $this->links->find((int) ($args['id'] ?? 0));
|
||||||
|
if ($link === null) {
|
||||||
|
return $this->linkNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->html('links/form', [
|
||||||
|
'link' => $link,
|
||||||
|
'domains' => $this->allDomains(),
|
||||||
|
'values' => [
|
||||||
|
'target_url' => (string) $link['target_url'],
|
||||||
|
'slug' => (string) $link['slug'],
|
||||||
|
'title' => (string) ($link['title'] ?? ''),
|
||||||
|
'is_active' => ((int) $link['is_active'] === 1) ? '1' : '0',
|
||||||
|
'domain_id' => (string) $link['domain_id'],
|
||||||
|
],
|
||||||
|
'errors' => [],
|
||||||
|
'pageTitle' => 'Kurzlink bearbeiten',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function update(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) ($args['id'] ?? 0);
|
||||||
|
$link = $this->links->find($id);
|
||||||
|
if ($link === null) {
|
||||||
|
return $this->linkNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$domains = $this->allDomains();
|
||||||
|
$values = $this->readValues($request);
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// Ohne Angabe bleibt der Link auf seiner bisherigen Domain.
|
||||||
|
$domainId = (int) $link['domain_id'];
|
||||||
|
if ($values['domain_id'] !== '') {
|
||||||
|
if ($this->isKnownDomain($domains, (int) $values['domain_id'])) {
|
||||||
|
$domainId = (int) $values['domain_id'];
|
||||||
|
} else {
|
||||||
|
$errors['domain_id'] = 'Bitte eine gültige Domain auswählen.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$values['domain_id'] = (string) $domainId;
|
||||||
|
|
||||||
|
$targetUrl = UrlValidator::normalize($values['target_url']);
|
||||||
|
$urlError = UrlValidator::validationError($targetUrl);
|
||||||
|
if ($urlError !== null) {
|
||||||
|
$errors['target_url'] = $urlError;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = Slug::normalize($values['slug']);
|
||||||
|
$slugError = Slug::validationError($slug);
|
||||||
|
if ($slugError !== null) {
|
||||||
|
$errors['slug'] = $slugError;
|
||||||
|
} elseif ($this->links->slugExists($slug, $domainId, $id)) {
|
||||||
|
$errors['slug'] = 'Dieser Slug ist auf der gewählten Domain bereits vergeben.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors !== []) {
|
||||||
|
return $this->html('links/form', [
|
||||||
|
'link' => $link,
|
||||||
|
'domains' => $domains,
|
||||||
|
'values' => $values,
|
||||||
|
'errors' => $errors,
|
||||||
|
'pageTitle' => 'Kurzlink bearbeiten',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->links->update(
|
||||||
|
$id,
|
||||||
|
$domainId,
|
||||||
|
$slug,
|
||||||
|
$targetUrl,
|
||||||
|
$values['title'] !== '' ? $values['title'] : null,
|
||||||
|
$values['is_active'] === '1',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->session->flash('success', 'Änderungen wurden gespeichert.');
|
||||||
|
|
||||||
|
return $this->redirect('/admin/links');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function toggle(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $this->links->find((int) ($args['id'] ?? 0));
|
||||||
|
if ($link === null) {
|
||||||
|
return $this->linkNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$active = (int) $link['is_active'] !== 1;
|
||||||
|
$this->links->setActive((int) $link['id'], $active);
|
||||||
|
|
||||||
|
$this->session->flash('success', sprintf(
|
||||||
|
'Kurzlink "%s" wurde %s.',
|
||||||
|
(string) $link['slug'],
|
||||||
|
$active ? 'aktiviert' : 'deaktiviert',
|
||||||
|
));
|
||||||
|
|
||||||
|
return $this->redirect('/admin/links');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $this->links->find((int) ($args['id'] ?? 0));
|
||||||
|
if ($link === null) {
|
||||||
|
return $this->linkNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$design = $this->designs->forLink((int) $link['id']);
|
||||||
|
if ($design->hasLogo()) {
|
||||||
|
$this->logos->delete($design->logoPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->links->delete((int) $link['id']);
|
||||||
|
$this->session->flash('success', sprintf('Kurzlink "%s" wurde gelöscht.', (string) $link['slug']));
|
||||||
|
|
||||||
|
return $this->redirect('/admin/links');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function allDomains(): array
|
||||||
|
{
|
||||||
|
// Sorgt nebenbei dafuer, dass die Standarddomain existiert.
|
||||||
|
$this->domains->defaultDomain($this->config->baseUrl());
|
||||||
|
|
||||||
|
return $this->domains->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $domains
|
||||||
|
*/
|
||||||
|
private function isKnownDomain(array $domains, int $id): bool
|
||||||
|
{
|
||||||
|
if ($id <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($domains as $domain) {
|
||||||
|
if ((int) $domain['id'] === $id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $domains
|
||||||
|
*/
|
||||||
|
private function baseUrlOf(array $domains, int $id): string
|
||||||
|
{
|
||||||
|
foreach ($domains as $domain) {
|
||||||
|
if ((int) $domain['id'] === $id) {
|
||||||
|
return (string) $domain['base_url'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->config->baseUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{target_url:string,slug:string,title:string,is_active:string,domain_id:string}
|
||||||
|
*/
|
||||||
|
private function readValues(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'target_url' => $request->post('target_url'),
|
||||||
|
'slug' => $request->post('slug'),
|
||||||
|
'title' => mb_substr($request->post('title'), 0, 255),
|
||||||
|
'is_active' => $request->hasPost('is_active') ? '1' : '0',
|
||||||
|
'domain_id' => $request->post('domain_id'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function linkNotFound(): Response
|
||||||
|
{
|
||||||
|
return $this->html('errors/error', [
|
||||||
|
'title' => 'Kurzlink nicht gefunden',
|
||||||
|
'message' => 'Der gewünschte Kurzlink existiert nicht (mehr).',
|
||||||
|
'pageTitle' => 'Nicht gefunden',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
|
use App\Http\Request;
|
||||||
|
use App\Http\Response;
|
||||||
|
use App\Repository\DomainRepository;
|
||||||
|
use App\Repository\LinkRepository;
|
||||||
|
use App\Repository\VisitRepository;
|
||||||
|
use App\Support\UrlValidator;
|
||||||
|
use App\Support\UserAgentParser;
|
||||||
|
use App\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der einzige oeffentlich erreichbare Teil der Anwendung: /{slug}.
|
||||||
|
*
|
||||||
|
* Welche Domain gemeint ist, ergibt sich aus dem Host-Header. Damit
|
||||||
|
* koennen "short.example.de/messe" und "qr.example.de/messe" auf
|
||||||
|
* unterschiedliche Ziele zeigen.
|
||||||
|
*/
|
||||||
|
final class RedirectController extends Controller
|
||||||
|
{
|
||||||
|
/** @var list<int> */
|
||||||
|
private const ALLOWED_STATUS = [301, 302, 307, 308];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
Config $config,
|
||||||
|
View $view,
|
||||||
|
private readonly LinkRepository $links,
|
||||||
|
private readonly VisitRepository $visits,
|
||||||
|
private readonly DomainRepository $domains,
|
||||||
|
) {
|
||||||
|
parent::__construct($config, $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
$slug = $args['slug'] ?? '';
|
||||||
|
|
||||||
|
$domain = $this->domains->resolveForRequest(
|
||||||
|
(string) ($request->server['HTTP_HOST'] ?? ''),
|
||||||
|
$this->config->baseUrl(),
|
||||||
|
);
|
||||||
|
$domainId = (int) $domain['id'];
|
||||||
|
|
||||||
|
// Bequemlichkeit: /abc/ und /ABC finden denselben Link.
|
||||||
|
$link = $this->links->findBySlug($slug, $domainId)
|
||||||
|
?? $this->links->findBySlug(strtolower($slug), $domainId);
|
||||||
|
|
||||||
|
if ($link === null || (int) $link['is_active'] !== 1) {
|
||||||
|
return $this->notFound($request, ['slug' => $slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $this->redirectStatus();
|
||||||
|
|
||||||
|
// Der Location-Header muss ASCII sein: Umlaut-Domains werden hier
|
||||||
|
// (und nur hier) nach Punycode gewandelt. Gespeichert bleibt die
|
||||||
|
// lesbare Schreibweise.
|
||||||
|
$response = Response::redirect(
|
||||||
|
UrlValidator::toAsciiUrl((string) $link['target_url']),
|
||||||
|
$status,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Weiterleitungen duerfen nicht zwischengespeichert werden,
|
||||||
|
// sonst gehen Klicks in der Statistik verloren.
|
||||||
|
if ($status === 301 || $status === 308) {
|
||||||
|
$response = $response->withHeader('Cache-Control', 'public, max-age=300');
|
||||||
|
} else {
|
||||||
|
$response = $response
|
||||||
|
->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
||||||
|
->withHeader('Pragma', 'no-cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protokollierung laeuft erst NACH dem Ausliefern der Antwort.
|
||||||
|
$linkId = (int) $link['id'];
|
||||||
|
$referrer = $request->referrer();
|
||||||
|
$userAgent = $request->userAgent();
|
||||||
|
$visitorHash = $this->visitorHash($request);
|
||||||
|
|
||||||
|
return $response->afterSend(function () use ($linkId, $referrer, $userAgent, $visitorHash): void {
|
||||||
|
$agent = UserAgentParser::parse($userAgent);
|
||||||
|
|
||||||
|
$this->links->registerClick($linkId);
|
||||||
|
$this->visits->record(
|
||||||
|
$linkId,
|
||||||
|
$referrer !== '' ? $referrer : null,
|
||||||
|
$userAgent !== '' ? $userAgent : null,
|
||||||
|
$agent['device'],
|
||||||
|
$agent['browser'],
|
||||||
|
$agent['platform'],
|
||||||
|
$visitorHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alte Einzelbesuche gelegentlich aufraeumen (Datensparsamkeit).
|
||||||
|
$retention = $this->config->getInt('VISIT_RETENTION_DAYS', 365);
|
||||||
|
if ($retention > 0 && random_int(1, 200) === 1) {
|
||||||
|
$this->visits->pruneOlderThan($retention);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function notFound(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
return $this->html('errors/404', [
|
||||||
|
'slug' => $args['slug'] ?? ltrim($request->path, '/'),
|
||||||
|
], 404, 'layout_plain');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redirectStatus(): int
|
||||||
|
{
|
||||||
|
$status = $this->config->getInt('REDIRECT_STATUS', 302);
|
||||||
|
|
||||||
|
return in_array($status, self::ALLOWED_STATUS, true) ? $status : 302;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionaler, gesalzener und gekuerzter Besucher-Hash.
|
||||||
|
* Bei TRACK_IP=none (Standard) wird gar nichts abgeleitet.
|
||||||
|
*/
|
||||||
|
private function visitorHash(Request $request): ?string
|
||||||
|
{
|
||||||
|
if (strtolower($this->config->getString('TRACK_IP', 'none')) !== 'hash') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$salt = $this->config->getString('IP_HASH_SALT', '');
|
||||||
|
if ($salt === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagesabhaengiger Salt -> kein dauerhaftes Wiedererkennen.
|
||||||
|
return substr(
|
||||||
|
hash('sha256', $salt . '|' . gmdate('Y-m-d') . '|' . $request->clientIp() . '|' . $request->userAgent()),
|
||||||
|
0,
|
||||||
|
32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
|
use App\Http\Request;
|
||||||
|
use App\Http\Response;
|
||||||
|
use App\Security\Auth;
|
||||||
|
use App\Security\Csrf;
|
||||||
|
use App\Security\RateLimiter;
|
||||||
|
use App\Security\Session;
|
||||||
|
use App\Security\SetupToken;
|
||||||
|
use App\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup-Assistent fuer den ersten Start: legt das Admin-Konto an.
|
||||||
|
*
|
||||||
|
* Zusaetzlich zum Formular ist ein Einmal-Token noetig, das nur aus dem
|
||||||
|
* Datenverzeichnis bzw. dem Serverprotokoll zu lesen ist. Damit kann
|
||||||
|
* eine frisch hochgeladene Installation nicht von jemand anderem
|
||||||
|
* uebernommen werden, der sie zufaellig zuerst aufruft.
|
||||||
|
*
|
||||||
|
* Ist bereits ein Konto vorhanden (per .env oder Datenbank), ist die
|
||||||
|
* Route vollstaendig gesperrt.
|
||||||
|
*/
|
||||||
|
final class SetupController extends Controller
|
||||||
|
{
|
||||||
|
private const MIN_PASSWORD_LENGTH = 10;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
Config $config,
|
||||||
|
View $view,
|
||||||
|
private readonly Session $session,
|
||||||
|
private readonly Csrf $csrf,
|
||||||
|
private readonly Auth $auth,
|
||||||
|
private readonly SetupToken $setupToken,
|
||||||
|
private readonly RateLimiter $rateLimiter,
|
||||||
|
) {
|
||||||
|
parent::__construct($config, $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request): Response
|
||||||
|
{
|
||||||
|
if ($this->auth->isConfigured()) {
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setupToken->ensure();
|
||||||
|
|
||||||
|
return $this->render($this->config->adminUser(), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Request $request): Response
|
||||||
|
{
|
||||||
|
if ($this->auth->isConfigured()) {
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setupToken->ensure();
|
||||||
|
|
||||||
|
// Dieselbe Sperre wie beim Login: das Token soll nicht
|
||||||
|
// durchprobiert werden koennen.
|
||||||
|
$ip = $request->clientIp();
|
||||||
|
if ($this->rateLimiter->isLocked($ip)) {
|
||||||
|
$seconds = $this->rateLimiter->retryAfter($ip);
|
||||||
|
|
||||||
|
return $this->render($request->post('username'), [
|
||||||
|
'token' => sprintf(
|
||||||
|
'Zu viele Fehlversuche. Bitte in %d Minuten erneut versuchen.',
|
||||||
|
max(1, (int) ceil($seconds / 60)),
|
||||||
|
),
|
||||||
|
], 429)->withHeader('Retry-After', (string) $seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = $request->post('username');
|
||||||
|
$password = (string) ($request->post['password'] ?? '');
|
||||||
|
$confirm = (string) ($request->post['password_confirm'] ?? '');
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
if (!$this->setupToken->matches($request->post('setup_token'))) {
|
||||||
|
$this->rateLimiter->recordFailure($ip);
|
||||||
|
usleep(300000);
|
||||||
|
$errors['token'] = 'Das Einrichtungs-Token stimmt nicht.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^[A-Za-z0-9._@-]{3,64}$/', $username)) {
|
||||||
|
$errors['username'] = 'Der Benutzername muss 3-64 Zeichen lang sein (Buchstaben, Ziffern, . _ - @).';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($password) < self::MIN_PASSWORD_LENGTH) {
|
||||||
|
$errors['password'] = sprintf('Das Passwort muss mindestens %d Zeichen lang sein.', self::MIN_PASSWORD_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($password !== $confirm) {
|
||||||
|
$errors['password_confirm'] = 'Die beiden Passwoerter stimmen nicht ueberein.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors !== []) {
|
||||||
|
return $this->render($username, $errors, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auth->configure($username, $password);
|
||||||
|
$this->setupToken->discard();
|
||||||
|
$this->rateLimiter->recordSuccess($ip);
|
||||||
|
|
||||||
|
$this->auth->login($username);
|
||||||
|
$this->session->flash('success', 'Einrichtung abgeschlossen. Willkommen!');
|
||||||
|
|
||||||
|
return $this->redirect('/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $errors
|
||||||
|
*/
|
||||||
|
private function render(string $username, array $errors, int $status = 200): Response
|
||||||
|
{
|
||||||
|
return $this->html('setup/index', [
|
||||||
|
'username' => $username,
|
||||||
|
'errors' => $errors,
|
||||||
|
'minLength' => self::MIN_PASSWORD_LENGTH,
|
||||||
|
'tokenPath' => $this->setupToken->path(),
|
||||||
|
], $status, 'layout_plain');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
|
use App\Http\Request;
|
||||||
|
use App\Http\Response;
|
||||||
|
use App\Repository\LinkRepository;
|
||||||
|
use App\Repository\VisitRepository;
|
||||||
|
use App\Security\Auth;
|
||||||
|
use App\Security\Session;
|
||||||
|
use App\Support\UserAgentParser;
|
||||||
|
use App\View;
|
||||||
|
|
||||||
|
final class StatsController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
Config $config,
|
||||||
|
View $view,
|
||||||
|
private readonly Auth $auth,
|
||||||
|
private readonly LinkRepository $links,
|
||||||
|
private readonly VisitRepository $visits,
|
||||||
|
) {
|
||||||
|
parent::__construct($config, $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function show(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (!$this->auth->check()) {
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $this->links->find((int) ($args['id'] ?? 0));
|
||||||
|
if ($link === null) {
|
||||||
|
return $this->html('errors/error', [
|
||||||
|
'title' => 'Kurzlink nicht gefunden',
|
||||||
|
'message' => 'Der gewünschte Kurzlink existiert nicht (mehr).',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = $this->days($request);
|
||||||
|
$linkId = (int) $link['id'];
|
||||||
|
|
||||||
|
$devices = $this->visits->breakdown($linkId, 'device_type');
|
||||||
|
$devices = array_map(
|
||||||
|
static fn (array $row): array => [
|
||||||
|
'label' => UserAgentParser::deviceLabel($row['label']),
|
||||||
|
'clicks' => $row['clicks'],
|
||||||
|
],
|
||||||
|
$devices,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->html('stats/show', [
|
||||||
|
'link' => $link,
|
||||||
|
'shortUrl' => LinkRepository::shortUrlOf($link, $this->config->baseUrl()),
|
||||||
|
'days' => $days,
|
||||||
|
'totalVisits' => $this->visits->totalForLink($linkId),
|
||||||
|
'series' => $this->visits->clicksPerDay($linkId, $days),
|
||||||
|
'referrers' => $this->visits->topReferrers($linkId),
|
||||||
|
'devices' => $devices,
|
||||||
|
'browsers' => $this->visits->breakdown($linkId, 'browser'),
|
||||||
|
'platforms' => $this->visits->breakdown($linkId, 'platform'),
|
||||||
|
'recent' => $this->visits->recent($linkId),
|
||||||
|
'pageTitle' => 'Statistik: ' . $link['slug'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datenendpunkt fuer das Diagramm (wird beim Zeitraumwechsel geladen).
|
||||||
|
*
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function data(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (!$this->auth->check()) {
|
||||||
|
return Response::json(['error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $this->links->find((int) ($args['id'] ?? 0));
|
||||||
|
if ($link === null) {
|
||||||
|
return Response::json(['error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = $this->days($request);
|
||||||
|
|
||||||
|
return Response::json([
|
||||||
|
'days' => $days,
|
||||||
|
'series' => $this->visits->clicksPerDay((int) $link['id'], $days),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function days(Request $request): int
|
||||||
|
{
|
||||||
|
$days = $request->queryInt('days', 30);
|
||||||
|
|
||||||
|
return in_array($days, [7, 30, 90, 365], true) ? $days : 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Database;
|
||||||
|
|
||||||
|
final class LinkRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Spalten der Domain, die bei Listen und Detailabfragen mitgelesen
|
||||||
|
* werden, damit die Kurz-URL ohne zweite Abfrage gebaut werden kann.
|
||||||
|
*/
|
||||||
|
private const DOMAIN_COLUMNS = 'd.base_url AS domain_base_url, d.host AS domain_host, d.label AS domain_label';
|
||||||
|
|
||||||
|
public function __construct(private readonly Database $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Slug ist nur innerhalb seiner Domain eindeutig.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
public function findBySlug(string $slug, int $domainId): ?array
|
||||||
|
{
|
||||||
|
return $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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
public function find(int $id): ?array
|
||||||
|
{
|
||||||
|
return $this->db->fetchOne(
|
||||||
|
'SELECT l.*, ' . self::DOMAIN_COLUMNS . '
|
||||||
|
FROM links l LEFT JOIN domains d ON d.id = l.domain_id
|
||||||
|
WHERE l.id = ?',
|
||||||
|
[$id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function slugExists(string $slug, int $domainId, ?int $exceptId = null): bool
|
||||||
|
{
|
||||||
|
if ($exceptId === null) {
|
||||||
|
return $this->db->fetchValue(
|
||||||
|
'SELECT 1 FROM links WHERE slug = ? AND domain_id = ?',
|
||||||
|
[$slug, $domainId],
|
||||||
|
) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->fetchValue(
|
||||||
|
'SELECT 1 FROM links WHERE slug = ? AND domain_id = ? AND id <> ?',
|
||||||
|
[$slug, $domainId, $exceptId],
|
||||||
|
) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
public function all(
|
||||||
|
string $search = '',
|
||||||
|
string $sort = 'created_desc',
|
||||||
|
int $limit = 200,
|
||||||
|
int $offset = 0,
|
||||||
|
?int $domainId = null,
|
||||||
|
): array {
|
||||||
|
$conditions = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$conditions[] = '(l.slug LIKE ? OR l.target_url LIKE ? OR l.title LIKE ?)';
|
||||||
|
$needle = '%' . $search . '%';
|
||||||
|
$params = [$needle, $needle, $needle];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($domainId !== null) {
|
||||||
|
$conditions[] = 'l.domain_id = ?';
|
||||||
|
$params[] = $domainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$where = $conditions === [] ? '' : 'WHERE ' . implode(' AND ', $conditions);
|
||||||
|
|
||||||
|
$order = match ($sort) {
|
||||||
|
'created_asc' => 'l.created_at ASC',
|
||||||
|
'clicks_desc' => 'l.click_count DESC, l.created_at DESC',
|
||||||
|
'clicks_asc' => 'l.click_count ASC, l.created_at DESC',
|
||||||
|
'slug_asc' => 'l.slug ASC',
|
||||||
|
default => 'l.created_at DESC',
|
||||||
|
};
|
||||||
|
|
||||||
|
return $this->db->fetchAll(
|
||||||
|
sprintf(
|
||||||
|
'SELECT l.*, %s
|
||||||
|
FROM links l LEFT JOIN domains d ON d.id = l.domain_id
|
||||||
|
%s ORDER BY %s LIMIT %d OFFSET %d',
|
||||||
|
self::DOMAIN_COLUMNS,
|
||||||
|
$where,
|
||||||
|
$order,
|
||||||
|
$limit,
|
||||||
|
max(0, $offset),
|
||||||
|
),
|
||||||
|
$params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count(string $search = '', ?int $domainId = null): int
|
||||||
|
{
|
||||||
|
$conditions = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
$conditions[] = '(slug LIKE ? OR target_url LIKE ? OR title LIKE ?)';
|
||||||
|
$needle = '%' . $search . '%';
|
||||||
|
$params = [$needle, $needle, $needle];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($domainId !== null) {
|
||||||
|
$conditions[] = 'domain_id = ?';
|
||||||
|
$params[] = $domainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$where = $conditions === [] ? '' : 'WHERE ' . implode(' AND ', $conditions);
|
||||||
|
|
||||||
|
return (int) $this->db->fetchValue('SELECT COUNT(*) FROM links ' . $where, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(
|
||||||
|
int $domainId,
|
||||||
|
string $slug,
|
||||||
|
string $targetUrl,
|
||||||
|
?string $title = null,
|
||||||
|
bool $isActive = true,
|
||||||
|
): int {
|
||||||
|
$now = gmdate('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO links (domain_id, slug, target_url, title, is_active, click_count, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, ?, ?)',
|
||||||
|
[$domainId, $slug, $targetUrl, $title, $isActive ? 1 : 0, $now, $now],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(
|
||||||
|
int $id,
|
||||||
|
int $domainId,
|
||||||
|
string $slug,
|
||||||
|
string $targetUrl,
|
||||||
|
?string $title,
|
||||||
|
bool $isActive,
|
||||||
|
): void {
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE links SET domain_id = ?, slug = ?, target_url = ?, title = ?, is_active = ?, updated_at = ?
|
||||||
|
WHERE id = ?',
|
||||||
|
[$domainId, $slug, $targetUrl, $title, $isActive ? 1 : 0, gmdate('Y-m-d H:i:s'), $id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setActive(int $id, bool $isActive): void
|
||||||
|
{
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE links SET is_active = ?, updated_at = ? WHERE id = ?',
|
||||||
|
[$isActive ? 1 : 0, gmdate('Y-m-d H:i:s'), $id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): void
|
||||||
|
{
|
||||||
|
// Explizit loeschen, da SQLite Foreign Keys je nach Build
|
||||||
|
// nicht zwingend erzwingt.
|
||||||
|
$this->db->execute('DELETE FROM visits WHERE link_id = ?', [$id]);
|
||||||
|
$this->db->execute('DELETE FROM qr_designs WHERE link_id = ?', [$id]);
|
||||||
|
$this->db->execute('DELETE FROM links WHERE id = ?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zaehlt einen Klick. Bewusst eine einzelne, indexgestuetzte Abfrage.
|
||||||
|
*/
|
||||||
|
public function registerClick(int $id): void
|
||||||
|
{
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE links SET click_count = click_count + 1, last_clicked_at = ? WHERE id = ?',
|
||||||
|
[gmdate('Y-m-d H:i:s'), $id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{links:int,clicks:int,active:int}
|
||||||
|
*/
|
||||||
|
public function summary(?int $domainId = null): array
|
||||||
|
{
|
||||||
|
$where = $domainId === null ? '' : 'WHERE domain_id = ?';
|
||||||
|
$params = $domainId === null ? [] : [$domainId];
|
||||||
|
|
||||||
|
$row = $this->db->fetchOne(
|
||||||
|
'SELECT COUNT(*) AS links,
|
||||||
|
COALESCE(SUM(click_count), 0) AS clicks,
|
||||||
|
COALESCE(SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END), 0) AS active
|
||||||
|
FROM links ' . $where,
|
||||||
|
$params,
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'links' => (int) ($row['links'] ?? 0),
|
||||||
|
'clicks' => (int) ($row['clicks'] ?? 0),
|
||||||
|
'active' => (int) ($row['active'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurz-URL eines Datensatzes aus der Liste bzw. Detailabfrage.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $link
|
||||||
|
*/
|
||||||
|
public static function shortUrlOf(array $link, string $fallbackBaseUrl): string
|
||||||
|
{
|
||||||
|
$base = isset($link['domain_base_url']) && $link['domain_base_url'] !== null
|
||||||
|
? (string) $link['domain_base_url']
|
||||||
|
: $fallbackBaseUrl;
|
||||||
|
|
||||||
|
return rtrim($base, '/') . '/' . (string) $link['slug'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key-Value-Speicher fuer Einstellungen, die zur Laufzeit gesetzt werden
|
||||||
|
* (z. B. das Admin-Passwort aus dem Setup-Assistenten).
|
||||||
|
*/
|
||||||
|
final class SettingRepository
|
||||||
|
{
|
||||||
|
public const ADMIN_USER = 'admin_user';
|
||||||
|
public const ADMIN_PASSWORD_HASH = 'admin_password_hash';
|
||||||
|
public const APP_SECRET = 'app_secret';
|
||||||
|
|
||||||
|
public function __construct(private readonly Database $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, ?string $default = null): ?string
|
||||||
|
{
|
||||||
|
$row = $this->db->fetchOne('SELECT value FROM settings WHERE key_name = ?', [$key]);
|
||||||
|
|
||||||
|
if ($row === null || $row['value'] === null || $row['value'] === '') {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $row['value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installationsgeheimnis fuer signierte Tokens. Wird beim ersten
|
||||||
|
* Bedarf erzeugt und bleibt danach unveraendert.
|
||||||
|
*/
|
||||||
|
public function secret(): string
|
||||||
|
{
|
||||||
|
$secret = $this->get(self::APP_SECRET);
|
||||||
|
|
||||||
|
if ($secret === null || strlen($secret) < 32) {
|
||||||
|
$secret = bin2hex(random_bytes(32));
|
||||||
|
$this->set(self::APP_SECRET, $secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(string $key, string $value): void
|
||||||
|
{
|
||||||
|
$now = gmdate('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$exists = $this->db->fetchValue('SELECT 1 FROM settings WHERE key_name = ?', [$key]);
|
||||||
|
|
||||||
|
if ($exists !== null) {
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE settings SET value = ?, updated_at = ? WHERE key_name = ?',
|
||||||
|
[$value, $now, $key],
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO settings (key_name, value, updated_at) VALUES (?, ?, ?)',
|
||||||
|
[$key, $value, $now],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Database;
|
||||||
|
|
||||||
|
final class VisitRepository
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Database $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function record(
|
||||||
|
int $linkId,
|
||||||
|
?string $referrerUrl,
|
||||||
|
?string $userAgent,
|
||||||
|
string $device,
|
||||||
|
string $browser,
|
||||||
|
string $platform,
|
||||||
|
?string $visitorHash,
|
||||||
|
): void {
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO visits
|
||||||
|
(link_id, visited_at, referrer_host, referrer_url, user_agent, device_type, browser, platform, visitor_hash)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[
|
||||||
|
$linkId,
|
||||||
|
gmdate('Y-m-d H:i:s'),
|
||||||
|
self::hostOf($referrerUrl),
|
||||||
|
$referrerUrl !== null && $referrerUrl !== '' ? mb_substr($referrerUrl, 0, 2048) : null,
|
||||||
|
$userAgent !== null && $userAgent !== '' ? mb_substr($userAgent, 0, 512) : null,
|
||||||
|
$device,
|
||||||
|
$browser,
|
||||||
|
$platform,
|
||||||
|
$visitorHash,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hostOf(?string $url): ?string
|
||||||
|
{
|
||||||
|
if ($url === null || trim($url) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = parse_url($url, PHP_URL_HOST);
|
||||||
|
|
||||||
|
if (!is_string($host) || $host === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_substr(strtolower(preg_replace('/^www\./', '', $host) ?? $host), 0, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function totalForLink(int $linkId): int
|
||||||
|
{
|
||||||
|
return (int) $this->db->fetchValue('SELECT COUNT(*) FROM visits WHERE link_id = ?', [$linkId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klicks pro Tag der letzten $days Tage, luecken aufgefuellt.
|
||||||
|
*
|
||||||
|
* @return list<array{date:string,clicks:int}>
|
||||||
|
*/
|
||||||
|
public function clicksPerDay(int $linkId, int $days = 30): array
|
||||||
|
{
|
||||||
|
$since = gmdate('Y-m-d', strtotime(sprintf('-%d days', $days - 1)) ?: time());
|
||||||
|
|
||||||
|
$rows = $this->db->fetchAll(
|
||||||
|
'SELECT SUBSTR(visited_at, 1, 10) AS day, COUNT(*) AS clicks
|
||||||
|
FROM visits
|
||||||
|
WHERE link_id = ? AND SUBSTR(visited_at, 1, 10) >= ?
|
||||||
|
GROUP BY SUBSTR(visited_at, 1, 10)
|
||||||
|
ORDER BY day ASC',
|
||||||
|
[$linkId, $since],
|
||||||
|
);
|
||||||
|
|
||||||
|
$byDay = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$byDay[(string) $row['day']] = (int) $row['clicks'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$series = [];
|
||||||
|
for ($i = $days - 1; $i >= 0; $i--) {
|
||||||
|
$day = gmdate('Y-m-d', strtotime(sprintf('-%d days', $i)) ?: time());
|
||||||
|
$series[] = ['date' => $day, 'clicks' => $byDay[$day] ?? 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $series;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{label:string,clicks:int}>
|
||||||
|
*/
|
||||||
|
public function topReferrers(int $linkId, int $limit = 10): array
|
||||||
|
{
|
||||||
|
$rows = $this->db->fetchAll(
|
||||||
|
'SELECT COALESCE(referrer_host, ?) AS label, COUNT(*) AS clicks
|
||||||
|
FROM visits
|
||||||
|
WHERE link_id = ?
|
||||||
|
GROUP BY COALESCE(referrer_host, ?)
|
||||||
|
ORDER BY clicks DESC
|
||||||
|
LIMIT ' . max(1, $limit),
|
||||||
|
['Direkt / unbekannt', $linkId, 'Direkt / unbekannt'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (array $row): array => ['label' => (string) $row['label'], 'clicks' => (int) $row['clicks']],
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{label:string,clicks:int}>
|
||||||
|
*/
|
||||||
|
public function breakdown(int $linkId, string $column, int $limit = 10): array
|
||||||
|
{
|
||||||
|
$allowed = ['device_type', 'browser', 'platform'];
|
||||||
|
if (!in_array($column, $allowed, true)) {
|
||||||
|
throw new \InvalidArgumentException('Ungueltige Spalte: ' . $column);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->fetchAll(
|
||||||
|
sprintf(
|
||||||
|
'SELECT %1$s AS label, COUNT(*) AS clicks
|
||||||
|
FROM visits
|
||||||
|
WHERE link_id = ?
|
||||||
|
GROUP BY %1$s
|
||||||
|
ORDER BY clicks DESC
|
||||||
|
LIMIT %2$d',
|
||||||
|
$column,
|
||||||
|
max(1, $limit),
|
||||||
|
),
|
||||||
|
[$linkId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (array $row): array => ['label' => (string) $row['label'], 'clicks' => (int) $row['clicks']],
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
public function recent(int $linkId, int $limit = 25): array
|
||||||
|
{
|
||||||
|
return $this->db->fetchAll(
|
||||||
|
sprintf(
|
||||||
|
'SELECT visited_at, referrer_host, device_type, browser, platform
|
||||||
|
FROM visits WHERE link_id = ? ORDER BY visited_at DESC, id DESC LIMIT %d',
|
||||||
|
max(1, $limit),
|
||||||
|
),
|
||||||
|
[$linkId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klicks aller Links der letzten $days Tage (Dashboard).
|
||||||
|
*
|
||||||
|
* @return list<array{date:string,clicks:int}>
|
||||||
|
*/
|
||||||
|
public function clicksPerDayGlobal(int $days = 30): array
|
||||||
|
{
|
||||||
|
$since = gmdate('Y-m-d', strtotime(sprintf('-%d days', $days - 1)) ?: time());
|
||||||
|
|
||||||
|
$rows = $this->db->fetchAll(
|
||||||
|
'SELECT SUBSTR(visited_at, 1, 10) AS day, COUNT(*) AS clicks
|
||||||
|
FROM visits
|
||||||
|
WHERE SUBSTR(visited_at, 1, 10) >= ?
|
||||||
|
GROUP BY SUBSTR(visited_at, 1, 10)
|
||||||
|
ORDER BY day ASC',
|
||||||
|
[$since],
|
||||||
|
);
|
||||||
|
|
||||||
|
$byDay = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$byDay[(string) $row['day']] = (int) $row['clicks'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$series = [];
|
||||||
|
for ($i = $days - 1; $i >= 0; $i--) {
|
||||||
|
$day = gmdate('Y-m-d', strtotime(sprintf('-%d days', $i)) ?: time());
|
||||||
|
$series[] = ['date' => $day, 'clicks' => $byDay[$day] ?? 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $series;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loescht Einzelbesuche aelter als $days Tage (Datensparsamkeit).
|
||||||
|
* Die Gesamt-Klickzahl am Link bleibt davon unberuehrt.
|
||||||
|
*/
|
||||||
|
public function pruneOlderThan(int $days): int
|
||||||
|
{
|
||||||
|
if ($days <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = gmdate('Y-m-d H:i:s', time() - $days * 86400);
|
||||||
|
$statement = $this->db->execute('DELETE FROM visits WHERE visited_at < ?', [$cutoff]);
|
||||||
|
|
||||||
|
return $statement->rowCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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