diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php new file mode 100644 index 0000000..31b0042 --- /dev/null +++ b/src/Controller/AuthController.php @@ -0,0 +1,109 @@ +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'); + } +} diff --git a/src/Controller/Controller.php b/src/Controller/Controller.php new file mode 100644 index 0000000..3fd883a --- /dev/null +++ b/src/Controller/Controller.php @@ -0,0 +1,94 @@ +basePath() . '/' . ltrim($path, '/'); + } + + protected function redirect(string $path, int $status = 302): Response + { + return Response::redirect($this->url($path), $status); + } + + /** + * @param array $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, + ); + } +} diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php new file mode 100644 index 0000000..9256341 --- /dev/null +++ b/src/Controller/DashboardController.php @@ -0,0 +1,52 @@ +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', + ]); + } +} diff --git a/src/Controller/LinkController.php b/src/Controller/LinkController.php new file mode 100644 index 0000000..7c38c06 --- /dev/null +++ b/src/Controller/LinkController.php @@ -0,0 +1,391 @@ +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 $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 $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 $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 $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> + */ + private function allDomains(): array + { + // Sorgt nebenbei dafuer, dass die Standarddomain existiert. + $this->domains->defaultDomain($this->config->baseUrl()); + + return $this->domains->all(); + } + + /** + * @param list> $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> $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); + } +} diff --git a/src/Controller/RedirectController.php b/src/Controller/RedirectController.php new file mode 100644 index 0000000..0a06299 --- /dev/null +++ b/src/Controller/RedirectController.php @@ -0,0 +1,147 @@ + */ + 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 $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 $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, + ); + } +} diff --git a/src/Controller/SetupController.php b/src/Controller/SetupController.php new file mode 100644 index 0000000..844f8e9 --- /dev/null +++ b/src/Controller/SetupController.php @@ -0,0 +1,131 @@ +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 $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'); + } +} diff --git a/src/Controller/StatsController.php b/src/Controller/StatsController.php new file mode 100644 index 0000000..f092020 --- /dev/null +++ b/src/Controller/StatsController.php @@ -0,0 +1,103 @@ + $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 $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; + } +} diff --git a/src/Repository/LinkRepository.php b/src/Repository/LinkRepository.php new file mode 100644 index 0000000..a3ea1b8 --- /dev/null +++ b/src/Repository/LinkRepository.php @@ -0,0 +1,231 @@ +|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|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> + */ + 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 $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']; + } +} diff --git a/src/Repository/SettingRepository.php b/src/Repository/SettingRepository.php new file mode 100644 index 0000000..2fc02ea --- /dev/null +++ b/src/Repository/SettingRepository.php @@ -0,0 +1,70 @@ +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], + ); + } +} diff --git a/src/Repository/VisitRepository.php b/src/Repository/VisitRepository.php new file mode 100644 index 0000000..8baae9b --- /dev/null +++ b/src/Repository/VisitRepository.php @@ -0,0 +1,207 @@ +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 + */ + 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 + */ + 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 + */ + 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> + */ + 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 + */ + 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(); + } +} diff --git a/src/Support/Punycode.php b/src/Support/Punycode.php new file mode 100644 index 0000000..c7d2cc5 --- /dev/null +++ b/src/Support/Punycode.php @@ -0,0 +1,197 @@ + $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 + */ + 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 $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)); + } +} diff --git a/src/Support/Slug.php b/src/Support/Slug.php new file mode 100644 index 0000000..d882d0d --- /dev/null +++ b/src/Support/Slug.php @@ -0,0 +1,138 @@ + + */ + 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; + } +} diff --git a/src/Support/UrlValidator.php b/src/Support/UrlValidator.php new file mode 100644 index 0000000..0d7601f --- /dev/null +++ b/src/Support/UrlValidator.php @@ -0,0 +1,276 @@ + 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 = '#^' + . '(?[a-zA-Z][a-zA-Z0-9+.-]*)://' + . '(?:(?[^/?\#@]*)@)?' + . '(?\[[0-9A-Fa-f:.]+\]|[^/?\#:]*)' + . '(?::(?[0-9]*))?' + . '(?/[^?\#]*)?' + . '(?:\?(?[^\#]*))?' + . '(?:\#(?.*))?' + . '$#'; + + 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; + } +} diff --git a/src/Support/UserAgentParser.php b/src/Support/UserAgentParser.php new file mode 100644 index 0000000..a5fa929 --- /dev/null +++ b/src/Support/UserAgentParser.php @@ -0,0 +1,119 @@ + 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', + }; + } +}