diff --git a/agb.php b/agb.php index 1c549c9..4d3366f 100644 --- a/agb.php +++ b/agb.php @@ -1,5 +1,7 @@ diff --git a/app/analytics-relay.php b/app/analytics-relay.php new file mode 100644 index 0000000..d1787d8 --- /dev/null +++ b/app/analytics-relay.php @@ -0,0 +1,279 @@ + $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 $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 + */ +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 $payload + * @param array $server + * @param array $cookies + * @return array + */ +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 $parameters + * @param null|callable(string, array): 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, + ]; +} diff --git a/app/analytics.php b/app/analytics.php index 27a0323..6902400 100644 --- a/app/analytics.php +++ b/app/analytics.php @@ -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 '' . "\n"; + return '' . "\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