280 lines
9.7 KiB
PHP
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,
|
|
];
|
|
}
|