Matomo-Tracking und Aufrufzaehlung absichern
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user