Files

280 lines
9.7 KiB
PHP

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