$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, ]; }