Matomo-Tracking und Aufrufzaehlung absichern

This commit is contained in:
2026-08-27 12:20:56 +02:00
parent 528af07890
commit c86803bbd6
26 changed files with 1019 additions and 106 deletions
+279
View File
@@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/analytics.php';
/**
* Der Browser spricht fuer die Analyse nur mit der bereits besuchten
* Kaffeeliste-Domain. Dieser Baustein validiert die wenigen erlaubten Werte
* und sendet sie anschliessend an die fest konfigurierte Matomo-Site.
* Insbesondere werden weder die Client-IP noch Benutzer- oder Formulardaten
* uebernommen.
*/
/** @param array<string, string> $cookies */
function app_analytics_consent_is_granted(array $cookies): bool
{
return hash_equals(
APP_ANALYTICS_CONSENT_VERSION . ':granted',
(string)($cookies[APP_ANALYTICS_CONSENT_COOKIE] ?? '')
);
}
/** @param array<string, mixed> $server */
function app_analytics_request_origin_is_valid(array $server): bool
{
$fetchSite = strtolower(trim((string)($server['HTTP_SEC_FETCH_SITE'] ?? '')));
if ($fetchSite !== '' && $fetchSite !== 'same-origin') {
return false;
}
$origin = trim((string)($server['HTTP_ORIGIN'] ?? ''));
$requestHost = trim((string)($server['HTTP_HOST'] ?? ''));
if ($origin === '' || $requestHost === '') {
return false;
}
$originParts = parse_url($origin);
$requestParts = parse_url('http://' . $requestHost);
if (!is_array($originParts) || !is_array($requestParts)) {
return false;
}
$originScheme = strtolower((string)($originParts['scheme'] ?? ''));
$expectedScheme = (!empty($server['HTTPS']) && $server['HTTPS'] !== 'off')
|| (string)($server['SERVER_PORT'] ?? '') === '443'
? 'https'
: 'http';
return $originScheme === $expectedScheme
&& strtolower((string)($originParts['host'] ?? '')) === strtolower((string)($requestParts['host'] ?? ''))
&& (int)($originParts['port'] ?? ($originScheme === 'https' ? 443 : 80))
=== (int)($requestParts['port'] ?? ($expectedScheme === 'https' ? 443 : 80));
}
/**
* @return list<string>
*/
function app_analytics_allowed_hosts(string $requestHost): array
{
$hosts = app_is_dev() ? [$requestHost] : [];
$appHost = trim((string)app_primary_host());
if ($appHost !== '') {
$hosts[] = strtolower($appHost);
}
$marketingHost = strtolower((string)parse_url((string)app_env('APP_MARKETING_URL', ''), PHP_URL_HOST));
if ($marketingHost !== '') {
$hosts[] = $marketingHost;
}
return array_values(array_unique($hosts));
}
/**
* Entfernt Query und Fragment nochmals serverseitig und erlaubt nur die
* konfigurierten Kaffeeliste-Hosts. Bei der aktuellen Seite muss der Host
* zusaetzlich dem Host des First-Party-Requests entsprechen.
*/
function app_analytics_clean_url(string $value, string $requestHost, bool $isCurrentPage): ?string
{
if ($value === '' || strlen($value) > 2048 || preg_match('/[\x00-\x1F\x7F]/', $value) === 1) {
return null;
}
$parts = parse_url($value);
if (
!is_array($parts)
|| !in_array(strtolower((string)($parts['scheme'] ?? '')), ['http', 'https'], true)
|| trim((string)($parts['host'] ?? '')) === ''
|| isset($parts['user'])
|| isset($parts['pass'])
) {
return null;
}
$host = strtolower((string)$parts['host']);
$requestParts = parse_url('http://' . $requestHost);
$normalizedRequestHost = strtolower((string)($requestParts['host'] ?? ''));
if ($normalizedRequestHost === '') {
return null;
}
if ($isCurrentPage && $host !== $normalizedRequestHost) {
return null;
}
if (!in_array($host, app_analytics_allowed_hosts($normalizedRequestHost), true)) {
return null;
}
$scheme = strtolower((string)$parts['scheme']);
if (!app_is_dev() && $scheme !== 'https') {
return null;
}
if ($isCurrentPage) {
$pagePort = (int)($parts['port'] ?? ($scheme === 'https' ? 443 : 80));
$requestPort = (int)($requestParts['port'] ?? ($scheme === 'https' ? 443 : 80));
if ($pagePort !== $requestPort) {
return null;
}
}
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
$path = (string)($parts['path'] ?? '/');
if ($path === '' || !str_starts_with($path, '/')) {
$path = '/' . $path;
}
return $scheme . '://' . $host . $port . $path;
}
function app_analytics_clean_text(string $value, int $maximumLength): string
{
$value = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', strip_tags($value)) ?? '');
return mb_substr($value, 0, $maximumLength);
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $server
* @param array<string, string> $cookies
* @return array<string, string>
*/
function app_matomo_page_view_parameters(array $payload, array $server, array $cookies): array
{
if (!app_analytics_consent_is_granted($cookies)) {
throw new RuntimeException('Keine Analyse-Einwilligung.');
}
if (!app_analytics_request_origin_is_valid($server)) {
throw new RuntimeException('Ungültiger Anfrageursprung.');
}
$visitorId = strtolower(trim((string)($payload['visitorId'] ?? '')));
$visitorCookie = strtolower(trim((string)($cookies[APP_ANALYTICS_VISITOR_COOKIE] ?? '')));
if (preg_match('/^[0-9a-f]{16}$/', $visitorId) !== 1 || !hash_equals($visitorCookie, $visitorId)) {
throw new RuntimeException('Ungültige Analyse-Besucherkennung.');
}
$requestHost = trim((string)($server['HTTP_HOST'] ?? ''));
$pageUrl = app_analytics_clean_url((string)($payload['pageUrl'] ?? ''), $requestHost, true);
if ($pageUrl === null) {
throw new RuntimeException('Ungültige Seiten-URL.');
}
$pageTitle = app_analytics_clean_text((string)($payload['pageTitle'] ?? ''), 200);
if ($pageTitle === '') {
$pageTitle = 'Kaffeeliste';
}
$parameters = [
'rec' => '1',
'apiv' => '1',
'send_image' => '0',
'action_name' => $pageTitle,
'url' => $pageUrl,
'_id' => $visitorId,
'rand' => (string)random_int(100000, 999999999),
'cookie' => '1',
];
$referrer = app_analytics_clean_url((string)($payload['referrerUrl'] ?? ''), $requestHost, false);
if ($referrer !== null) {
$parameters['urlref'] = $referrer;
}
$resolution = trim((string)($payload['resolution'] ?? ''));
if (preg_match('/^[1-9][0-9]{0,4}x[1-9][0-9]{0,4}$/', $resolution) === 1) {
$parameters['res'] = $resolution;
}
$language = app_analytics_clean_text((string)($payload['language'] ?? ''), 35);
if ($language !== '' && preg_match('/^[A-Za-z0-9._-]+$/', $language) === 1) {
$parameters['lang'] = $language;
}
foreach (['h' => 23, 'm' => 59, 's' => 59] as $name => $maximum) {
$value = filter_var($payload[$name] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 0, 'max_range' => $maximum],
]);
if ($value !== false) {
$parameters[$name] = (string)$value;
}
}
// Der User-Agent ist vom Einwilligungstext umfasst. Die Client-IP wird
// bewusst nicht als Tracking-IP an Matomo weitergegeben.
$userAgent = app_analytics_clean_text((string)($server['HTTP_USER_AGENT'] ?? ''), 500);
if ($userAgent !== '') {
$parameters['ua'] = $userAgent;
}
return $parameters;
}
/**
* @param array<string, string> $parameters
* @param null|callable(string, array<string, string>): array{ok: bool, status: int, error: ?string} $transport
* @return array{ok: bool, status: int, error: ?string}
*/
function app_matomo_forward_page_view(array $parameters, ?callable $transport = null): array
{
$configuration = app_matomo_configuration();
if ($configuration === null) {
return ['ok' => false, 'status' => 0, 'error' => 'Matomo ist nicht konfiguriert.'];
}
$parameters['idsite'] = $configuration['siteId'];
$trackerUrl = $configuration['matomoUrl'] . 'matomo.php';
if ($transport !== null) {
return $transport($trackerUrl, $parameters);
}
$body = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
if (function_exists('curl_init')) {
$curl = curl_init($trackerUrl);
if ($curl === false) {
return ['ok' => false, 'status' => 0, 'error' => 'cURL konnte nicht initialisiert werden.'];
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 6,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$response = curl_exec($curl);
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = $response === false ? curl_error($curl) : null;
curl_close($curl);
return [
'ok' => $response !== false && $status >= 200 && $status < 300,
'status' => $status,
'error' => $error,
];
}
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\nConnection: close",
'content' => $body,
'timeout' => 6,
'ignore_errors' => true,
]]);
$response = @file_get_contents($trackerUrl, false, $context);
$status = 0;
foreach ($http_response_header ?? [] as $header) {
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $matches) === 1) {
$status = (int)$matches[1];
}
}
return [
'ok' => $response !== false && $status >= 200 && $status < 300,
'status' => $status,
'error' => $response === false ? 'Keine Antwort vom Matomo-Endpunkt.' : null,
];
}
+21 -6
View File
@@ -5,7 +5,8 @@ declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
const APP_ANALYTICS_CONSENT_COOKIE = 'kaffeeliste_analytics_consent';
const APP_ANALYTICS_CONSENT_VERSION = '1';
const APP_ANALYTICS_VISITOR_COOKIE = 'kaffeeliste_analytics_visitor';
const APP_ANALYTICS_CONSENT_VERSION = '2';
const APP_ANALYTICS_CONSENT_MAX_AGE = 15552000; // 180 Tage
/**
@@ -71,7 +72,7 @@ function app_matomo_configuration_errors(): array
}
/**
* @return array{matomoUrl: string, siteId: string, consentCookie: string, consentVersion: string, consentMaxAge: int, cookieDomain: string, secureCookies: bool}|null
* @return array{matomoUrl: string, siteId: string, consentCookie: string, visitorCookie: string, consentVersion: string, consentMaxAge: int, cookieDomain: string, secureCookies: bool}|null
*/
function app_matomo_configuration(): ?array
{
@@ -90,6 +91,7 @@ function app_matomo_configuration(): ?array
'matomoUrl' => rtrim($url, '/') . '/',
'siteId' => $siteId,
'consentCookie' => APP_ANALYTICS_CONSENT_COOKIE,
'visitorCookie' => APP_ANALYTICS_VISITOR_COOKIE,
'consentVersion' => APP_ANALYTICS_CONSENT_VERSION,
'consentMaxAge' => APP_ANALYTICS_CONSENT_MAX_AGE,
'cookieDomain' => trim((string)app_env('MATOMO_CONSENT_COOKIE_DOMAIN', '')),
@@ -103,7 +105,7 @@ function app_analytics_head_html(): string
return '';
}
return '<link rel="stylesheet" href="assets/css/analytics-consent.css">' . "\n";
return '<link rel="stylesheet" href="assets/css/privacy-settings.css">' . "\n";
}
function app_analytics_body_html(): string
@@ -113,8 +115,21 @@ function app_analytics_body_html(): string
return '';
}
// Matomo-Host und Site-ID bleiben serverseitig. Der Browser sendet nach
// Einwilligung nur an den First-Party-Endpunkt; dieser leitet die eng
// validierte Seitenansicht an die fest konfigurierte Matomo-Site weiter.
$browserConfiguration = [
'consentCookie' => $configuration['consentCookie'],
'visitorCookie' => $configuration['visitorCookie'],
'consentVersion' => $configuration['consentVersion'],
'consentMaxAge' => $configuration['consentMaxAge'],
'cookieDomain' => $configuration['cookieDomain'],
'secureCookies' => $configuration['secureCookies'],
'trackingEndpoint' => 'nutzungsanalyse.php',
];
$json = json_encode(
$configuration,
$browserConfiguration,
JSON_UNESCAPED_SLASHES
| JSON_UNESCAPED_UNICODE
| JSON_HEX_TAG
@@ -131,7 +146,7 @@ function app_analytics_body_html(): string
<div class="analytics-consent__inner">
<div class="analytics-consent__copy">
<h2 id="analytics-consent-title">Optionale Nutzungsanalyse</h2>
<p id="analytics-consent-description">Wir möchten mit Matomo verstehen, wie unsere Website und App genutzt werden. Erst nach deiner Zustimmung laden wir Matomo und setzen Analyse-Cookies. Notwendige Cookies funktionieren immer. Mehr dazu steht im <a href="datenschutz.php">Datenschutz</a>.</p>
<p id="analytics-consent-description">Wir möchten mit Matomo verstehen, wie unsere Website und App genutzt werden. Erst nach deiner Zustimmung übermitteln wir Seitenaufrufe an unser selbst betriebenes Matomo und setzen ein Analyse-Cookie. Notwendige Cookies funktionieren immer. Mehr dazu steht im <a href="datenschutz.php">Datenschutz</a>.</p>
</div>
<div class="analytics-consent__actions">
<button type="button" data-analytics-consent="denied">Nur notwendige</button>
@@ -142,5 +157,5 @@ function app_analytics_body_html(): string
<button type="button" class="analytics-consent-settings" id="analytics-consent-settings" hidden>Cookie-Einstellungen</button>
HTML
. "\n<script type=\"application/json\" id=\"analytics-configuration\">{$json}</script>\n"
. '<script src="assets/js/analytics-consent.js" defer></script>' . "\n";
. '<script src="assets/js/privacy-settings.js" defer></script>' . "\n";
}
+1 -1
View File
@@ -5,7 +5,7 @@ declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
const APP_TERMS_VERSION = '2026-08-22';
const APP_PRIVACY_VERSION = '2026-08-27';
const APP_PRIVACY_VERSION = '2026-08-27-2';
const APP_DPA_VERSION = '2026-08-22';
const APP_WITHDRAWAL_VERSION = '2026-08-22';
+1
View File
@@ -27,6 +27,7 @@ function app_database_table_names(): array
'payment_import_rows',
'stripe_webhook_events',
'totp_recovery_codes',
'public_page_views',
'rate_limit_attempts',
'tenant_memberships',
'legal_acceptances',
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/database.php';
/**
* Fester Katalog der gezaehlten oeffentlichen Seiten. Ein Request-Wert darf
* nie zum Seitenschluessel werden: So koennen insbesondere Query-Parameter,
* Token oder frei eingegebene Inhalte nicht in der Statistik landen.
*
* @return array<string, string>
*/
function app_public_page_view_labels(): array
{
return [
'landing' => 'Startseite',
'prices' => 'Preise',
'register' => 'Registrierung',
'login' => 'Login',
'privacy' => 'Datenschutz',
'imprint' => 'Impressum',
'terms' => 'AGB',
'dpa' => 'AVV',
'withdrawal_information' => 'Widerrufsbelehrung',
];
}
/**
* Erhoeht ausschliesslich einen aggregierten Tageszaehler. Es werden keine
* Request-Metadaten gelesen oder gespeichert. Schreibfehler duerfen eine
* oeffentliche Seite nicht unbenutzbar machen, etwa waehrend eines Deploys,
* bei dem die Migration wenige Sekunden nach dem Code eingespielt wird.
*/
function app_record_public_page_view(string $pageKey, ?PDO $pdo = null): void
{
// Ein explizit injiziertes PDO ist nur fuer den CLI-Regressionstest
// vorgesehen. Normale CLI-Aufrufe sollen keine Seitenansicht erzeugen.
if (($pdo === null && PHP_SAPI === 'cli') || ($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
return;
}
if (!array_key_exists($pageKey, app_public_page_view_labels())) {
throw new InvalidArgumentException('Unbekannter öffentlicher Seitenschlüssel.');
}
try {
$stmt = ($pdo ?? app_db_pdo())->prepare(
'INSERT INTO public_page_views (view_date, page_key, view_count)
VALUES (CURRENT_DATE, ?, 1)
ON DUPLICATE KEY UPDATE view_count = view_count + 1'
);
$stmt->execute([$pageKey]);
} catch (Throwable $e) {
error_log('Anonymer Seitenzähler konnte nicht aktualisiert werden: ' . $e->getMessage());
}
}
/**
* @return array{
* totals: array{today: int, last_30_days: int, all_time: int},
* pages: list<array{page_key: string, label: string, today: int, last_30_days: int, all_time: int}>
* }
*/
function app_public_page_view_summary(PDO $pdo): array
{
$totalsRow = $pdo->query(
'SELECT
COALESCE(SUM(CASE WHEN view_date = CURRENT_DATE THEN view_count ELSE 0 END), 0) AS today,
COALESCE(SUM(CASE WHEN view_date >= CURRENT_DATE - INTERVAL 29 DAY THEN view_count ELSE 0 END), 0) AS last_30_days,
COALESCE(SUM(view_count), 0) AS all_time
FROM public_page_views'
)->fetch() ?: [];
$pageRows = $pdo->query(
'SELECT
page_key,
COALESCE(SUM(CASE WHEN view_date = CURRENT_DATE THEN view_count ELSE 0 END), 0) AS today,
COALESCE(SUM(CASE WHEN view_date >= CURRENT_DATE - INTERVAL 29 DAY THEN view_count ELSE 0 END), 0) AS last_30_days,
COALESCE(SUM(view_count), 0) AS all_time
FROM public_page_views
GROUP BY page_key
ORDER BY all_time DESC, page_key ASC'
)->fetchAll();
$labels = app_public_page_view_labels();
$pages = [];
foreach ($pageRows as $row) {
$pageKey = (string)$row['page_key'];
$pages[] = [
'page_key' => $pageKey,
'label' => $labels[$pageKey] ?? $pageKey,
'today' => (int)$row['today'],
'last_30_days' => (int)$row['last_30_days'],
'all_time' => (int)$row['all_time'],
];
}
return [
'totals' => [
'today' => (int)($totalsRow['today'] ?? 0),
'last_30_days' => (int)($totalsRow['last_30_days'] ?? 0),
'all_time' => (int)($totalsRow['all_time'] ?? 0),
],
'pages' => $pages,
];
}