Matomo-Tracking und Aufrufzaehlung absichern
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/app/legal.php';
|
require_once __DIR__ . '/app/legal.php';
|
||||||
|
require_once __DIR__ . '/app/public-page-views.php';
|
||||||
|
app_record_public_page_view('terms');
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE HTML>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
+21
-6
@@ -5,7 +5,8 @@ declare(strict_types=1);
|
|||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
const APP_ANALYTICS_CONSENT_COOKIE = 'kaffeeliste_analytics_consent';
|
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
|
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
|
function app_matomo_configuration(): ?array
|
||||||
{
|
{
|
||||||
@@ -90,6 +91,7 @@ function app_matomo_configuration(): ?array
|
|||||||
'matomoUrl' => rtrim($url, '/') . '/',
|
'matomoUrl' => rtrim($url, '/') . '/',
|
||||||
'siteId' => $siteId,
|
'siteId' => $siteId,
|
||||||
'consentCookie' => APP_ANALYTICS_CONSENT_COOKIE,
|
'consentCookie' => APP_ANALYTICS_CONSENT_COOKIE,
|
||||||
|
'visitorCookie' => APP_ANALYTICS_VISITOR_COOKIE,
|
||||||
'consentVersion' => APP_ANALYTICS_CONSENT_VERSION,
|
'consentVersion' => APP_ANALYTICS_CONSENT_VERSION,
|
||||||
'consentMaxAge' => APP_ANALYTICS_CONSENT_MAX_AGE,
|
'consentMaxAge' => APP_ANALYTICS_CONSENT_MAX_AGE,
|
||||||
'cookieDomain' => trim((string)app_env('MATOMO_CONSENT_COOKIE_DOMAIN', '')),
|
'cookieDomain' => trim((string)app_env('MATOMO_CONSENT_COOKIE_DOMAIN', '')),
|
||||||
@@ -103,7 +105,7 @@ function app_analytics_head_html(): string
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '<link rel="stylesheet" href="assets/css/analytics-consent.css">' . "\n";
|
return '<link rel="stylesheet" href="assets/css/privacy-settings.css">' . "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
function app_analytics_body_html(): string
|
function app_analytics_body_html(): string
|
||||||
@@ -113,8 +115,21 @@ function app_analytics_body_html(): string
|
|||||||
return '';
|
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(
|
$json = json_encode(
|
||||||
$configuration,
|
$browserConfiguration,
|
||||||
JSON_UNESCAPED_SLASHES
|
JSON_UNESCAPED_SLASHES
|
||||||
| JSON_UNESCAPED_UNICODE
|
| JSON_UNESCAPED_UNICODE
|
||||||
| JSON_HEX_TAG
|
| JSON_HEX_TAG
|
||||||
@@ -131,7 +146,7 @@ function app_analytics_body_html(): string
|
|||||||
<div class="analytics-consent__inner">
|
<div class="analytics-consent__inner">
|
||||||
<div class="analytics-consent__copy">
|
<div class="analytics-consent__copy">
|
||||||
<h2 id="analytics-consent-title">Optionale Nutzungsanalyse</h2>
|
<h2 id="analytics-consent-title">Optionale Nutzungsanalyse</h2>
|
||||||
<p id="analytics-consent-description">Wir möchten mit Matomo verstehen, wie unsere Website und App genutzt werden. Erst nach deiner Zustimmung laden wir Matomo und setzen Analyse-Cookies. Notwendige Cookies funktionieren immer. Mehr dazu steht im <a href="datenschutz.php">Datenschutz</a>.</p>
|
<p id="analytics-consent-description">Wir möchten mit Matomo verstehen, wie unsere Website und App genutzt werden. Erst nach deiner Zustimmung übermitteln wir Seitenaufrufe an unser selbst betriebenes Matomo und setzen ein Analyse-Cookie. Notwendige Cookies funktionieren immer. Mehr dazu steht im <a href="datenschutz.php">Datenschutz</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="analytics-consent__actions">
|
<div class="analytics-consent__actions">
|
||||||
<button type="button" data-analytics-consent="denied">Nur notwendige</button>
|
<button type="button" data-analytics-consent="denied">Nur notwendige</button>
|
||||||
@@ -142,5 +157,5 @@ function app_analytics_body_html(): string
|
|||||||
<button type="button" class="analytics-consent-settings" id="analytics-consent-settings" hidden>Cookie-Einstellungen</button>
|
<button type="button" class="analytics-consent-settings" id="analytics-consent-settings" hidden>Cookie-Einstellungen</button>
|
||||||
HTML
|
HTML
|
||||||
. "\n<script type=\"application/json\" id=\"analytics-configuration\">{$json}</script>\n"
|
. "\n<script type=\"application/json\" id=\"analytics-configuration\">{$json}</script>\n"
|
||||||
. '<script src="assets/js/analytics-consent.js" defer></script>' . "\n";
|
. '<script src="assets/js/privacy-settings.js" defer></script>' . "\n";
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
const APP_TERMS_VERSION = '2026-08-22';
|
const APP_TERMS_VERSION = '2026-08-22';
|
||||||
const APP_PRIVACY_VERSION = '2026-08-27';
|
const APP_PRIVACY_VERSION = '2026-08-27-2';
|
||||||
const APP_DPA_VERSION = '2026-08-22';
|
const APP_DPA_VERSION = '2026-08-22';
|
||||||
const APP_WITHDRAWAL_VERSION = '2026-08-22';
|
const APP_WITHDRAWAL_VERSION = '2026-08-22';
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function app_database_table_names(): array
|
|||||||
'payment_import_rows',
|
'payment_import_rows',
|
||||||
'stripe_webhook_events',
|
'stripe_webhook_events',
|
||||||
'totp_recovery_codes',
|
'totp_recovery_codes',
|
||||||
|
'public_page_views',
|
||||||
'rate_limit_attempts',
|
'rate_limit_attempts',
|
||||||
'tenant_memberships',
|
'tenant_memberships',
|
||||||
'legal_acceptances',
|
'legal_acceptances',
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/database.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fester Katalog der gezaehlten oeffentlichen Seiten. Ein Request-Wert darf
|
||||||
|
* nie zum Seitenschluessel werden: So koennen insbesondere Query-Parameter,
|
||||||
|
* Token oder frei eingegebene Inhalte nicht in der Statistik landen.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
function app_public_page_view_labels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'landing' => 'Startseite',
|
||||||
|
'prices' => 'Preise',
|
||||||
|
'register' => 'Registrierung',
|
||||||
|
'login' => 'Login',
|
||||||
|
'privacy' => 'Datenschutz',
|
||||||
|
'imprint' => 'Impressum',
|
||||||
|
'terms' => 'AGB',
|
||||||
|
'dpa' => 'AVV',
|
||||||
|
'withdrawal_information' => 'Widerrufsbelehrung',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erhoeht ausschliesslich einen aggregierten Tageszaehler. Es werden keine
|
||||||
|
* Request-Metadaten gelesen oder gespeichert. Schreibfehler duerfen eine
|
||||||
|
* oeffentliche Seite nicht unbenutzbar machen, etwa waehrend eines Deploys,
|
||||||
|
* bei dem die Migration wenige Sekunden nach dem Code eingespielt wird.
|
||||||
|
*/
|
||||||
|
function app_record_public_page_view(string $pageKey, ?PDO $pdo = null): void
|
||||||
|
{
|
||||||
|
// Ein explizit injiziertes PDO ist nur fuer den CLI-Regressionstest
|
||||||
|
// vorgesehen. Normale CLI-Aufrufe sollen keine Seitenansicht erzeugen.
|
||||||
|
if (($pdo === null && PHP_SAPI === 'cli') || ($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!array_key_exists($pageKey, app_public_page_view_labels())) {
|
||||||
|
throw new InvalidArgumentException('Unbekannter öffentlicher Seitenschlüssel.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = ($pdo ?? app_db_pdo())->prepare(
|
||||||
|
'INSERT INTO public_page_views (view_date, page_key, view_count)
|
||||||
|
VALUES (CURRENT_DATE, ?, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE view_count = view_count + 1'
|
||||||
|
);
|
||||||
|
$stmt->execute([$pageKey]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('Anonymer Seitenzähler konnte nicht aktualisiert werden: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* totals: array{today: int, last_30_days: int, all_time: int},
|
||||||
|
* pages: list<array{page_key: string, label: string, today: int, last_30_days: int, all_time: int}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
function app_public_page_view_summary(PDO $pdo): array
|
||||||
|
{
|
||||||
|
$totalsRow = $pdo->query(
|
||||||
|
'SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN view_date = CURRENT_DATE THEN view_count ELSE 0 END), 0) AS today,
|
||||||
|
COALESCE(SUM(CASE WHEN view_date >= CURRENT_DATE - INTERVAL 29 DAY THEN view_count ELSE 0 END), 0) AS last_30_days,
|
||||||
|
COALESCE(SUM(view_count), 0) AS all_time
|
||||||
|
FROM public_page_views'
|
||||||
|
)->fetch() ?: [];
|
||||||
|
|
||||||
|
$pageRows = $pdo->query(
|
||||||
|
'SELECT
|
||||||
|
page_key,
|
||||||
|
COALESCE(SUM(CASE WHEN view_date = CURRENT_DATE THEN view_count ELSE 0 END), 0) AS today,
|
||||||
|
COALESCE(SUM(CASE WHEN view_date >= CURRENT_DATE - INTERVAL 29 DAY THEN view_count ELSE 0 END), 0) AS last_30_days,
|
||||||
|
COALESCE(SUM(view_count), 0) AS all_time
|
||||||
|
FROM public_page_views
|
||||||
|
GROUP BY page_key
|
||||||
|
ORDER BY all_time DESC, page_key ASC'
|
||||||
|
)->fetchAll();
|
||||||
|
|
||||||
|
$labels = app_public_page_view_labels();
|
||||||
|
$pages = [];
|
||||||
|
foreach ($pageRows as $row) {
|
||||||
|
$pageKey = (string)$row['page_key'];
|
||||||
|
$pages[] = [
|
||||||
|
'page_key' => $pageKey,
|
||||||
|
'label' => $labels[$pageKey] ?? $pageKey,
|
||||||
|
'today' => (int)$row['today'],
|
||||||
|
'last_30_days' => (int)$row['last_30_days'],
|
||||||
|
'all_time' => (int)$row['all_time'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'totals' => [
|
||||||
|
'today' => (int)($totalsRow['today'] ?? 0),
|
||||||
|
'last_30_days' => (int)($totalsRow['last_30_days'] ?? 0),
|
||||||
|
'all_time' => (int)($totalsRow['all_time'] ?? 0),
|
||||||
|
],
|
||||||
|
'pages' => $pages,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -17,21 +17,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var cookieName = configuration.consentCookie;
|
var cookieName = configuration.consentCookie;
|
||||||
|
var visitorCookieName = configuration.visitorCookie;
|
||||||
var consentVersion = configuration.consentVersion;
|
var consentVersion = configuration.consentVersion;
|
||||||
var matomoStarted = false;
|
var pageViewSent = false;
|
||||||
|
|
||||||
function readDecision() {
|
function readCookie(name) {
|
||||||
var cookies = document.cookie ? document.cookie.split(';') : [];
|
var cookies = document.cookie ? document.cookie.split(';') : [];
|
||||||
for (var index = 0; index < cookies.length; index += 1) {
|
for (var index = 0; index < cookies.length; index += 1) {
|
||||||
var parts = cookies[index].trim().split('=');
|
var parts = cookies[index].trim().split('=');
|
||||||
var name = decodeURIComponent(parts.shift() || '');
|
var currentName = decodeURIComponent(parts.shift() || '');
|
||||||
if (name !== cookieName) {
|
if (currentName === name) {
|
||||||
continue;
|
return decodeURIComponent(parts.join('='));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var value = decodeURIComponent(parts.join('='));
|
function readDecision() {
|
||||||
|
var value = readCookie(cookieName);
|
||||||
var expectedPrefix = consentVersion + ':';
|
var expectedPrefix = consentVersion + ':';
|
||||||
if (value.indexOf(expectedPrefix) !== 0) {
|
if (!value || value.indexOf(expectedPrefix) !== 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,9 +44,6 @@
|
|||||||
return decision === 'granted' || decision === 'denied' ? decision : null;
|
return decision === 'granted' || decision === 'denied' ? decision : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieSuffix() {
|
function cookieSuffix() {
|
||||||
var suffix = '; Path=/; Max-Age=' + String(configuration.consentMaxAge) + '; SameSite=Lax';
|
var suffix = '; Path=/; Max-Age=' + String(configuration.consentMaxAge) + '; SameSite=Lax';
|
||||||
if (configuration.cookieDomain) {
|
if (configuration.cookieDomain) {
|
||||||
@@ -53,10 +55,29 @@
|
|||||||
return suffix;
|
return suffix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeCookie(name, value) {
|
||||||
|
document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value) + cookieSuffix();
|
||||||
|
}
|
||||||
|
|
||||||
function writeDecision(decision) {
|
function writeDecision(decision) {
|
||||||
document.cookie = encodeURIComponent(cookieName)
|
writeCookie(cookieName, consentVersion + ':' + decision);
|
||||||
+ '=' + encodeURIComponent(consentVersion + ':' + decision)
|
}
|
||||||
+ cookieSuffix();
|
|
||||||
|
function randomVisitorId() {
|
||||||
|
var bytes = new Uint8Array(8);
|
||||||
|
window.crypto.getRandomValues(bytes);
|
||||||
|
return Array.prototype.map.call(bytes, function (value) {
|
||||||
|
return value.toString(16).padStart(2, '0');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureVisitorId() {
|
||||||
|
var visitorId = readCookie(visitorCookieName);
|
||||||
|
if (!visitorId || !/^[0-9a-f]{16}$/.test(visitorId)) {
|
||||||
|
visitorId = randomVisitorId();
|
||||||
|
writeCookie(visitorCookieName, visitorId);
|
||||||
|
}
|
||||||
|
return visitorId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function URLWithoutParameters(value) {
|
function URLWithoutParameters(value) {
|
||||||
@@ -68,46 +89,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startMatomo() {
|
function sendPageView() {
|
||||||
window._paq = window._paq || [];
|
if (pageViewSent || readDecision() !== 'granted') {
|
||||||
|
|
||||||
if (matomoStarted) {
|
|
||||||
window._paq.push(['setConsentGiven']);
|
|
||||||
window._paq.push(['trackPageView']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
matomoStarted = true;
|
pageViewSent = true;
|
||||||
|
|
||||||
var trackerUrl = configuration.matomoUrl + 'matomo.php';
|
var now = new Date();
|
||||||
window._paq.push(['requireConsent']);
|
var payload = {
|
||||||
window._paq.push(['setTrackerUrl', trackerUrl]);
|
visitorId: ensureVisitorId(),
|
||||||
window._paq.push(['setSiteId', configuration.siteId]);
|
pageUrl: URLWithoutParameters(window.location.href),
|
||||||
window._paq.push(['setDoNotTrack', true]);
|
pageTitle: document.title || 'Kaffeeliste',
|
||||||
window._paq.push(['disableBrowserFeatureDetection']);
|
referrerUrl: URLWithoutParameters(document.referrer),
|
||||||
window._paq.push(['disablePerformanceTracking']);
|
resolution: window.screen && window.screen.width && window.screen.height
|
||||||
window._paq.push(['setCookiePath', '/']);
|
? String(window.screen.width) + 'x' + String(window.screen.height)
|
||||||
window._paq.push(['setCookieSameSite', 'Lax']);
|
: '',
|
||||||
window._paq.push(['setSecureCookie', window.location.protocol === 'https:']);
|
language: window.navigator.language || '',
|
||||||
window._paq.push(['setVisitorCookieTimeout', configuration.consentMaxAge]);
|
h: now.getHours(),
|
||||||
window._paq.push(['setReferralCookieTimeout', configuration.consentMaxAge]);
|
m: now.getMinutes(),
|
||||||
window._paq.push(['setCustomUrl', URLWithoutParameters(window.location.href)]);
|
s: now.getSeconds()
|
||||||
|
};
|
||||||
|
|
||||||
var referrer = URLWithoutParameters(document.referrer);
|
window.fetch(configuration.trackingEndpoint, {
|
||||||
if (referrer) {
|
method: 'POST',
|
||||||
window._paq.push(['setReferrerUrl', referrer]);
|
credentials: 'same-origin',
|
||||||
}
|
headers: {'Content-Type': 'application/json'},
|
||||||
if (configuration.cookieDomain) {
|
body: JSON.stringify(payload),
|
||||||
window._paq.push(['setCookieDomain', configuration.cookieDomain]);
|
keepalive: true
|
||||||
}
|
}).catch(function () {
|
||||||
|
// Analysefehler duerfen die Website niemals beeintraechtigen.
|
||||||
window._paq.push(['setConsentGiven']);
|
});
|
||||||
window._paq.push(['trackPageView']);
|
|
||||||
|
|
||||||
var script = document.createElement('script');
|
|
||||||
script.async = true;
|
|
||||||
script.src = configuration.matomoUrl + 'matomo.js';
|
|
||||||
script.referrerPolicy = 'strict-origin-when-cross-origin';
|
|
||||||
document.head.appendChild(script);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function expireCookie(name, domain) {
|
function expireCookie(name, domain) {
|
||||||
@@ -121,11 +132,11 @@
|
|||||||
document.cookie = value;
|
document.cookie = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMatomoCookies() {
|
function removeAnalysisCookies() {
|
||||||
var cookies = document.cookie ? document.cookie.split(';') : [];
|
var cookies = document.cookie ? document.cookie.split(';') : [];
|
||||||
cookies.forEach(function (cookie) {
|
cookies.forEach(function (cookie) {
|
||||||
var name = decodeURIComponent(cookie.trim().split('=')[0] || '');
|
var name = decodeURIComponent(cookie.trim().split('=')[0] || '');
|
||||||
if (name.indexOf('_pk_') !== 0 && name.indexOf('mtm_') !== 0) {
|
if (name !== visitorCookieName && name.indexOf('_pk_') !== 0 && name.indexOf('mtm_') !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expireCookie(name, '');
|
expireCookie(name, '');
|
||||||
@@ -135,14 +146,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopMatomo() {
|
|
||||||
if (window._paq) {
|
|
||||||
window._paq.push(['forgetConsentGiven']);
|
|
||||||
window._paq.push(['deleteCookies']);
|
|
||||||
}
|
|
||||||
removeMatomoCookies();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeBanner() {
|
function closeBanner() {
|
||||||
banner.hidden = true;
|
banner.hidden = true;
|
||||||
settingsButton.hidden = false;
|
settingsButton.hidden = false;
|
||||||
@@ -152,9 +155,9 @@
|
|||||||
writeDecision(decision);
|
writeDecision(decision);
|
||||||
closeBanner();
|
closeBanner();
|
||||||
if (decision === 'granted') {
|
if (decision === 'granted') {
|
||||||
startMatomo();
|
sendPageView();
|
||||||
} else {
|
} else {
|
||||||
stopMatomo();
|
removeAnalysisCookies();
|
||||||
}
|
}
|
||||||
window.dispatchEvent(new CustomEvent('kaffeeliste:analytics-consent', {
|
window.dispatchEvent(new CustomEvent('kaffeeliste:analytics-consent', {
|
||||||
detail: {analytics: decision}
|
detail: {analytics: decision}
|
||||||
@@ -179,10 +182,10 @@
|
|||||||
var initialDecision = readDecision();
|
var initialDecision = readDecision();
|
||||||
if (initialDecision === 'granted') {
|
if (initialDecision === 'granted') {
|
||||||
settingsButton.hidden = false;
|
settingsButton.hidden = false;
|
||||||
startMatomo();
|
sendPageView();
|
||||||
} else if (initialDecision === 'denied') {
|
} else if (initialDecision === 'denied') {
|
||||||
settingsButton.hidden = false;
|
settingsButton.hidden = false;
|
||||||
stopMatomo();
|
removeAnalysisCookies();
|
||||||
} else {
|
} else {
|
||||||
banner.hidden = false;
|
banner.hidden = false;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/app/legal.php';
|
require_once __DIR__ . '/app/legal.php';
|
||||||
|
require_once __DIR__ . '/app/public-page-views.php';
|
||||||
|
app_record_public_page_view('dpa');
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE HTML>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
|
|||||||
@@ -3,12 +3,21 @@
|
|||||||
require_once __DIR__ . '/functions.php';
|
require_once __DIR__ . '/functions.php';
|
||||||
require_once __DIR__ . '/app/platform-admin.php';
|
require_once __DIR__ . '/app/platform-admin.php';
|
||||||
require_once __DIR__ . '/app/billing.php';
|
require_once __DIR__ . '/app/billing.php';
|
||||||
|
require_once __DIR__ . '/app/public-page-views.php';
|
||||||
|
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
$user = app_require_platform_admin($pdo);
|
$user = app_require_platform_admin($pdo);
|
||||||
|
|
||||||
$tenants = app_backoffice_fetch_tenants($pdo);
|
$tenants = app_backoffice_fetch_tenants($pdo);
|
||||||
$billingPlans = billing_plans();
|
$billingPlans = billing_plans();
|
||||||
|
$pageViewSummary = null;
|
||||||
|
$pageViewError = false;
|
||||||
|
try {
|
||||||
|
$pageViewSummary = app_public_page_view_summary($pdo);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$pageViewError = true;
|
||||||
|
error_log('Back-Office-Seitenaufrufe konnten nicht geladen werden: ' . $e->getMessage());
|
||||||
|
}
|
||||||
$legalRequests = $pdo->query(
|
$legalRequests = $pdo->query(
|
||||||
"SELECT lr.id, lr.reference_code, lr.request_type, lr.requester_name,
|
"SELECT lr.id, lr.reference_code, lr.request_type, lr.requester_name,
|
||||||
lr.requester_email, lr.contract_reference, lr.request_reason,
|
lr.requester_email, lr.contract_reference, lr.request_reason,
|
||||||
@@ -31,6 +40,37 @@ include 'nav.php';
|
|||||||
<p>Nur für Betreiber-Admins sichtbar: Übersicht aller Mandanten, unabhängig von deren eigener Mitgliedschaft.
|
<p>Nur für Betreiber-Admins sichtbar: Übersicht aller Mandanten, unabhängig von deren eigener Mitgliedschaft.
|
||||||
Jeder Zugriff wird im Audit-Log des jeweiligen Mandanten protokolliert.</p>
|
Jeder Zugriff wird im Audit-Log des jeweiligen Mandanten protokolliert.</p>
|
||||||
|
|
||||||
|
<h3>Öffentliche Seitenaufrufe</h3>
|
||||||
|
<p><small>Anonyme Seitenaufrufe ohne Cookies, IP-Adressen oder Besucherkennungen. Wiederholte Aufrufe und automatisierte Abrufe können enthalten sein; die Zahlen sind keine eindeutigen Besucher.</small></p>
|
||||||
|
<?php if ($pageViewError || $pageViewSummary === null): ?>
|
||||||
|
<div class="hint-box warning"><p>Die Seitenaufrufe sind noch nicht verfügbar. Bitte Migration 0032 ausführen.</p></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<table>
|
||||||
|
<tr><th>Heute</th><th>Letzte 30 Tage</th><th>Seit Beginn der Zählung</th></tr>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo number_format($pageViewSummary['totals']['today'], 0, ',', '.'); ?></td>
|
||||||
|
<td><?php echo number_format($pageViewSummary['totals']['last_30_days'], 0, ',', '.'); ?></td>
|
||||||
|
<td><?php echo number_format($pageViewSummary['totals']['all_time'], 0, ',', '.'); ?></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<?php if ($pageViewSummary['pages'] === []): ?>
|
||||||
|
<div class="hint-box info"><p>Seit dem Start des Zählers wurden noch keine öffentlichen Seiten aufgerufen.</p></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<table>
|
||||||
|
<tr><th>Seite</th><th>Heute</th><th>Letzte 30 Tage</th><th>Gesamt</th></tr>
|
||||||
|
<?php foreach ($pageViewSummary['pages'] as $pageView): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo saas_html($pageView['label']); ?></td>
|
||||||
|
<td><?php echo number_format($pageView['today'], 0, ',', '.'); ?></td>
|
||||||
|
<td><?php echo number_format($pageView['last_30_days'], 0, ',', '.'); ?></td>
|
||||||
|
<td><?php echo number_format($pageView['all_time'], 0, ',', '.'); ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<h3>Offene Kündigungen und Widerrufe</h3>
|
<h3>Offene Kündigungen und Widerrufe</h3>
|
||||||
<?php if ($legalRequests === []): ?>
|
<?php if ($legalRequests === []): ?>
|
||||||
<div class="hint-box success"><p>Keine offenen Rechtserklärungen.</p></div>
|
<div class="hint-box success"><p>Keine offenen Rechtserklärungen.</p></div>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Anonyme Reichweitenzaehlung fuer die oeffentlichen HTML-Seiten.
|
||||||
|
-- Bewusst keine IP-Adresse, keine User-Agent-Daten, keine Session-/Cookie-ID
|
||||||
|
-- und kein Zeitstempel pro Abruf: gespeichert werden nur Tageszähler je
|
||||||
|
-- festem Seitenschluessel.
|
||||||
|
CREATE TABLE public_page_views (
|
||||||
|
view_date DATE NOT NULL,
|
||||||
|
page_key VARCHAR(64) NOT NULL,
|
||||||
|
view_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (page_key, view_date),
|
||||||
|
KEY idx_public_page_views_date (view_date)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/app/legal.php';
|
require_once __DIR__ . '/app/legal.php';
|
||||||
|
require_once __DIR__ . '/app/public-page-views.php';
|
||||||
|
app_record_public_page_view('privacy');
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE HTML>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
|
|||||||
+37
-10
@@ -150,7 +150,7 @@ den CSV-Import. Beide sind per `.htaccess` von außen gesperrt.
|
|||||||
|
|
||||||
### 6. Migrationen einspielen
|
### 6. Migrationen einspielen
|
||||||
|
|
||||||
Es gibt 30 versionierte Migrationen in `database/migrations/`, angewendet
|
Es gibt 32 versionierte Migrationen in `database/migrations/`, angewendet
|
||||||
über `scripts/migrate.php`. Das Skript ist idempotent und wendet nur
|
über `scripts/migrate.php`. Das Skript ist idempotent und wendet nur
|
||||||
fehlende Migrationen an. Es arbeitet ausschließlich im Prefix aus der
|
fehlende Migrationen an. Es arbeitet ausschließlich im Prefix aus der
|
||||||
jeweiligen `env.local.php`; daher müssen Migrationen einmal in Staging und
|
jeweiligen `env.local.php`; daher müssen Migrationen einmal in Staging und
|
||||||
@@ -360,9 +360,11 @@ putenv('MATOMO_PRIVACY_CONFIRMED=1');
|
|||||||
```
|
```
|
||||||
|
|
||||||
`MATOMO_SITE_ID` durch die numerische Website-ID ersetzen. Die gemeinsame
|
`MATOMO_SITE_ID` durch die numerische Website-ID ersetzen. Die gemeinsame
|
||||||
Cookie-Domain ist nur richtig, wenn Marketingseite, App und Matomo tatsächlich
|
Cookie-Domain ist nur richtig, wenn Marketingseite und App unter
|
||||||
unter `kaffeeliste.de` betrieben werden sollen. Sonst leer lassen; die
|
`kaffeeliste.de` laufen und dieselbe Entscheidung sowie Besucherkennung nutzen
|
||||||
Einwilligung gilt dann nur auf dem jeweils besuchten Host.
|
sollen. Der Host der selbst betriebenen Matomo-Instanz darf davon abweichen.
|
||||||
|
Sonst leer lassen; die Einwilligung gilt dann nur auf dem jeweils besuchten
|
||||||
|
Host.
|
||||||
|
|
||||||
`MATOMO_PRIVACY_CONFIRMED=1` erst setzen, nachdem die Matomo-Instanz so
|
`MATOMO_PRIVACY_CONFIRMED=1` erst setzen, nachdem die Matomo-Instanz so
|
||||||
konfiguriert und geprüft wurde:
|
konfiguriert und geprüft wurde:
|
||||||
@@ -374,16 +376,19 @@ konfiguriert und geprüft wurde:
|
|||||||
3. Roh- und Besuchsprotokolle werden spätestens nach 180 Tagen automatisch
|
3. Roh- und Besuchsprotokolle werden spätestens nach 180 Tagen automatisch
|
||||||
gelöscht; verdichtete Berichte spätestens nach 25 Monaten.
|
gelöscht; verdichtete Berichte spätestens nach 25 Monaten.
|
||||||
4. Keine User-ID, benutzerbezogene Custom Dimensions, Heatmaps,
|
4. Keine User-ID, benutzerbezogene Custom Dimensions, Heatmaps,
|
||||||
Sitzungsaufzeichnungen oder Werbeintegrationen aktivieren. Der eingebundene
|
Sitzungsaufzeichnungen oder Werbeintegrationen aktivieren. Der
|
||||||
Tracker deaktiviert zusätzlich Browser-Funktionserkennung,
|
First-Party-Tracker übermittelt nur den bereinigten Seitenaufruf, eine
|
||||||
Performance-Messung und Link-Klick-Tracking.
|
zufällige Besucherkennung und die im Datenschutzhinweis genannten wenigen
|
||||||
|
Browserangaben; Performance- und Link-Klick-Messung bleiben deaktiviert.
|
||||||
5. In Matomo ist genau eine Website mit den erlaubten Marketing- und
|
5. In Matomo ist genau eine Website mit den erlaubten Marketing- und
|
||||||
App-Domains angelegt. Testaufrufe mit Query-Parametern dürfen in Matomo nur
|
App-Domains angelegt. Testaufrufe mit Query-Parametern dürfen in Matomo nur
|
||||||
den Pfad ohne Query oder Fragment zeigen.
|
den Pfad ohne Query oder Fragment zeigen.
|
||||||
6. Im privaten Browserfenster prüfen: Vor der Entscheidung und nach „Nur
|
6. Im privaten Browserfenster prüfen: Vor der Entscheidung und nach „Nur
|
||||||
notwendige“ gibt es keine Anfrage an `matomo.js`/`matomo.php` und keine
|
notwendige“ gibt es keine Anfrage an `nutzungsanalyse.php` und kein
|
||||||
`_pk_*`-Cookies. Nach Zustimmung gibt es beides; ein anschließender Widerruf
|
`kaffeeliste_analytics_visitor`-Cookie. Nach Zustimmung antwortet der lokale
|
||||||
über „Cookie-Einstellungen“ stoppt weitere Aufrufe und löscht die Cookies.
|
POST an `nutzungsanalyse.php` mit HTTP 204 und der Aufruf erscheint in
|
||||||
|
Matomo. Ein anschließender Widerruf über „Cookie-Einstellungen“ stoppt
|
||||||
|
weitere Aufrufe und löscht das Besuchercookie.
|
||||||
|
|
||||||
Die Entscheidung wird 180 Tage im Cookie
|
Die Entscheidung wird 180 Tage im Cookie
|
||||||
`kaffeeliste_analytics_consent` gespeichert. Eine Versionsänderung in
|
`kaffeeliste_analytics_consent` gespeichert. Eine Versionsänderung in
|
||||||
@@ -391,6 +396,28 @@ Die Entscheidung wird 180 Tage im Cookie
|
|||||||
oder Empfängern erfordern außerdem eine neue Datenschutzerklärung und eine
|
oder Empfängern erfordern außerdem eine neue Datenschutzerklärung und eine
|
||||||
erneute rechtliche Prüfung.
|
erneute rechtliche Prüfung.
|
||||||
|
|
||||||
|
Der Browser lädt weder `matomo.js` noch `matomo.php` vom Analysehost. Nach der
|
||||||
|
Einwilligung sendet `assets/js/privacy-settings.js` genau einen bereinigten
|
||||||
|
Seitenaufruf an den gleichnamigen Host-Endpunkt `nutzungsanalyse.php`. Dieser
|
||||||
|
validiert Einwilligung, Origin, Besucherkennung und URL und leitet den Aufruf
|
||||||
|
serverseitig an die feste `MATOMO_URL`/`MATOMO_SITE_ID` weiter. Die Client-IP
|
||||||
|
wird nicht als Tracking-Parameter an Matomo übermittelt.
|
||||||
|
|
||||||
|
### Anonyme Grundzählung ohne Einwilligung
|
||||||
|
|
||||||
|
Unabhängig von Matomo zählt `app/public-page-views.php` die GET-Aufrufe
|
||||||
|
ausgewählter öffentlicher HTML-Seiten. In `public_page_views` stehen nur der
|
||||||
|
Kalendertag, ein fester Seitenschlüssel und ein Summenzähler. IP-Adresse,
|
||||||
|
User-Agent, Referrer, vollständige URL, Query-Parameter, Cookies und
|
||||||
|
Benutzerkennungen werden weder gelesen noch in diese Statistik übernommen.
|
||||||
|
|
||||||
|
Die Auswertung steht ausschließlich Platform-Admins im Back-Office zur
|
||||||
|
Verfügung und zeigt heute, die letzten 30 Tage, den Gesamtstand sowie die
|
||||||
|
Aufteilung je Seite. Sie misst Seitenaufrufe, keine eindeutigen Besucher;
|
||||||
|
Wiederholungen und Bots können enthalten sein. Die Zählung beginnt erst nach
|
||||||
|
Anwendung von `0032_public_page_views.sql` und kann frühere Zugriffe nicht
|
||||||
|
rückwirkend rekonstruieren.
|
||||||
|
|
||||||
## Betreiber-Benachrichtigung bei neuer Registrierung
|
## Betreiber-Benachrichtigung bei neuer Registrierung
|
||||||
|
|
||||||
`register.php` schickt nach einer erfolgreichen Selbstregistrierung einen
|
`register.php` schickt nach einer erfolgreichen Selbstregistrierung einen
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,8 @@
|
|||||||
<?php require_once __DIR__ . '/app/legal.php'; ?>
|
<?php
|
||||||
|
require_once __DIR__ . '/app/legal.php';
|
||||||
|
require_once __DIR__ . '/app/public-page-views.php';
|
||||||
|
app_record_public_page_view('imprint');
|
||||||
|
?>
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE HTML>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/app/bootstrap.php';
|
require_once __DIR__ . '/app/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/app/public-page-views.php';
|
||||||
|
|
||||||
|
app_record_public_page_view('landing');
|
||||||
|
|
||||||
// Absolute Basis-URL fuer Open-Graph/Canonical: die Landingpage laeuft auf der
|
// Absolute Basis-URL fuer Open-Graph/Canonical: die Landingpage laeuft auf der
|
||||||
// Marketing-Domain (nicht APP_HOST), daher aus dem aktuellen Request ableiten.
|
// Marketing-Domain (nicht APP_HOST), daher aus dem aktuellen Request ableiten.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
## 1. Verantwortlicher und Kontakt
|
||||||
|
|
||||||
|
Verantwortlich für die eigenen Verarbeitungen des Kaffeeliste-Dienstes ist Clemens Creutzburg, CTB-IT, In den Sieben Stücken 9d, 30655 Hannover, E-Mail: info@ctb-it.de. Telefonnummer und Ticketsystem stehen im [Impressum](impressum.php). Ein Datenschutzbeauftragter ist derzeit nicht bestellt, weil keine gesetzliche Bestellpflicht festgestellt wurde.
|
||||||
|
|
||||||
|
## 2. Unsere Rollen
|
||||||
|
|
||||||
|
Für Registrierung, Kundenkonto, Vertragsabwicklung, Abrechnung, Support, Sicherheit, die optionale Nutzungsanalyse und den Betrieb der öffentlichen Website ist CTB-IT Verantwortlicher.
|
||||||
|
|
||||||
|
Verwaltet ein Kunde Daten anderer Personen innerhalb seines Mandanten, ist grundsätzlich der Kunde Verantwortlicher und CTB-IT Auftragsverarbeiter. Dies regelt der [AVV](avv.php). Bei ausschließlich persönlichen oder familiären Tätigkeiten eines privaten Kunden kann die Haushaltsausnahme nach Art. 2 Abs. 2 lit. c DSGVO gelten. CTB-IT schützt die Daten technisch und vertraglich unabhängig davon.
|
||||||
|
|
||||||
|
## 3. Website, Serverprotokolle und anonyme Aufrufzahlen
|
||||||
|
|
||||||
|
Beim Aufruf werden technisch erforderliche Verbindungsdaten verarbeitet: IP-Adresse, Datum und Uhrzeit, angeforderte Adresse, übertragene Datenmenge, Referrer, Browser, Betriebssystem und HTTP-Status. Zweck sind sichere Auslieferung, Fehleranalyse und Abwehr von Angriffen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; das berechtigte Interesse ist ein sicherer und stabiler Dienst.
|
||||||
|
|
||||||
|
Anwendungsseitige IP-Daten aus der Begrenzung automatisierter Versuche werden spätestens nach 24 Stunden gelöscht. Webserver-Protokolle sollen im Produktivbetrieb nach spätestens sieben Tagen gelöscht oder anonymisiert werden, sofern ein Sicherheitsvorfall keine längere, zweckgebundene Sicherung erfordert.
|
||||||
|
|
||||||
|
Für eine einfache Reichweitenzählung erhöhen wir beim GET-Abruf ausgewählter öffentlicher HTML-Seiten einen Tageszähler. Gespeichert werden ausschließlich das Datum, ein fest vorgegebener Seitenschlüssel und die Anzahl der Aufrufe. IP-Adresse, User-Agent, Referrer, vollständige URL, URL-Parameter, Cookie-, Sitzungs- oder Benutzerkennung werden nicht in diese Statistik übernommen. Sie erlaubt daher weder die Ermittlung eindeutiger Besucher noch die Bildung von Nutzungsprofilen. Wiederholte und automatisierte Aufrufe können enthalten sein. Weil keine Information auf dem Endgerät gespeichert oder daraus ausgelesen wird, ist hierfür keine Einwilligung nach § 25 TDDDG erforderlich; die aggregierten Zähldaten weisen keinen Personenbezug auf.
|
||||||
|
|
||||||
|
## 4. Registrierung und Kundenkonto
|
||||||
|
|
||||||
|
Wir verarbeiten Kundenname und -kürzel, Kundentyp, Name, E-Mail-Adresse, Passwort-Hash, E-Mail-Bestätigung, Rollen, Anmeldezeitpunkte, Vertrags- und Rechtstextversionen, Zeitpunkt der Annahme, IP-Adresse und User-Agent. Zwecke sind Vertragsschluss, Authentifizierung, Berechtigungen, Vertragsnachweis und Kommunikation. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b DSGVO sowie für Sicherheits- und Nachweisdaten Art. 6 Abs. 1 lit. f DSGVO. Pflichtfelder sind für Vertragsschluss und sichere Kontoführung erforderlich; ohne sie ist keine Registrierung möglich.
|
||||||
|
|
||||||
|
Ein Klartextpasswort wird nicht gespeichert. Die Kenntnisnahme der Datenschutzerklärung ist keine Einwilligung in Werbung oder Analyse. Eine Einwilligung in die Nutzungsanalyse wird separat über die Cookie-Einstellungen abgefragt.
|
||||||
|
|
||||||
|
## 5. Mandanten-, Teilnehmer- und Kassendaten
|
||||||
|
|
||||||
|
Je nach Nutzung werden Einstellungen, Marken- und Zahlungsangaben des Kunden, Teilnehmername, E-Mail-Adresse, PayPal-Anzeigename und -E-Mail, Nutzerzuordnung, Rollen, Aktivstatus, Verbrauch, Einzahlungen, Salden, Bemerkungen, Hinweise, FAQ-Inhalte, Importe, Benachrichtigungsstatus und Administrationsprotokolle verarbeitet.
|
||||||
|
|
||||||
|
Bei Auftragsverarbeitung bestimmt der Kunde Zwecke, Rechtsgrundlagen, Inhalte und Empfänger. Die Daten stammen vom Kunden, seinen Administratoren, den Teilnehmern oder aus vom Kunden veranlassten Importen. Teilnehmer sollten sich für Auskunft, Berichtigung oder Löschung zuerst an den jeweiligen Kunden wenden; CTB-IT unterstützt diesen nach dem AVV.
|
||||||
|
|
||||||
|
## 6. Vertrag, Abrechnung, Stripe und Dolibarr
|
||||||
|
|
||||||
|
Bei kostenpflichtigen Tarifen verarbeiten wir Tarif, Teilnehmerzahl, Abrechnungsstatus, Stripe-Kunden- und Abonnementkennung, Laufzeit, Rechnungsbetrag und Zahlungsstatus zur Vertragsdurchführung nach Art. 6 Abs. 1 lit. b DSGVO und für Buchführung und Steuerrecht nach Art. 6 Abs. 1 lit. c DSGVO.
|
||||||
|
|
||||||
|
Zahlungen werden durch Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, Irland, abgewickelt. Stripe erhält insbesondere Kontakt-, Vertrags-, Geräte- und Zahlungsdaten. Stripe verarbeitet einen Teil als unser Auftragsverarbeiter und einen Teil in eigener Verantwortlichkeit, etwa zur Betrugsprävention und Erfüllung finanzrechtlicher Pflichten. Übermittlungen in die USA oder andere Drittländer können auf Angemessenheitsbeschlüssen, insbesondere dem EU-US Data Privacy Framework für zertifizierte Empfänger, oder EU-Standardvertragsklauseln beruhen. Weitere Informationen: [Stripe-Datenschutz](https://stripe.com/de/privacy).
|
||||||
|
|
||||||
|
Bezahlte Rechnungen werden im selbst betriebenen Dolibarr-System unter verwaltung.ctb-it.de für Buchführung und Kundenverwaltung gespiegelt. Dabei werden Kunden-, Vertrags-, Rechnungs- und Zahlungsdaten verarbeitet, aber grundsätzlich keine Teilnehmer- oder Kasseninhalte.
|
||||||
|
|
||||||
|
## 7. PayPal-Zahlungsmails
|
||||||
|
|
||||||
|
Aktiviert ein Kunde die PayPal-Zuordnung, werden an die mandantenspezifische Zahlungsadresse weitergeleitete PayPal-Benachrichtigungen automatisiert ausgelesen. Verarbeitet werden unter anderem Absender- und Empfängeradresse, PayPal-Transaktionskennung, Name und E-Mail des Zahlers, Betrag, Währung, Zahlungszeit und Zuordnungsstatus. Zweck ist die vom Kunden gewünschte Zuordnung zu Teilnehmern und Einzahlungen. Bei Teilnehmerdaten erfolgt dies im Auftrag des Kunden; für die Bereitstellung und Sicherheit der Funktion zusätzlich auf Art. 6 Abs. 1 lit. b und f DSGVO.
|
||||||
|
|
||||||
|
PayPal selbst verarbeitet Daten in eigener Verantwortlichkeit nach den dortigen Datenschutzinformationen. Die Kaffeeliste ruft keine PayPal-Kontodaten des Kunden über eine PayPal-API ab.
|
||||||
|
|
||||||
|
## 8. E-Mail, Support und Ticketsystem
|
||||||
|
|
||||||
|
Wir senden notwendige E-Mails zur Verifizierung, Anmeldung, Passwortzurücksetzung, Einladung, Vertragsbestätigung, Kündigung, Widerruf und zu vom Kunden aktivierten Kassenbenachrichtigungen. Verarbeitet werden Empfänger, Betreff, Status, Zeitpunkt und gegebenenfalls Fehler. Grundlage sind Art. 6 Abs. 1 lit. b DSGVO beziehungsweise bei Teilnehmernachrichten der Auftrag des Kunden. Versandprotokolle werden regelmäßig nach 180 Tagen gelöscht, soweit sie nicht als Vertragsnachweis länger erforderlich sind.
|
||||||
|
|
||||||
|
Bei Kontakt per E-Mail oder über ticketsystem.ctb-it.de verarbeiten wir die übermittelten Kontaktdaten, Inhalte, Anhänge, Zeitpunkte und technischen Protokolldaten, um die Anfrage zu bearbeiten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bei vorvertraglichen oder vertraglichen Anliegen, sonst Art. 6 Abs. 1 lit. f DSGVO. Unser Interesse ist nachvollziehbarer und sicherer Support. Bitte übermitteln Sie im Ticket keine unnötigen besonderen Kategorien personenbezogener Daten.
|
||||||
|
|
||||||
|
## 9. Hosting, Empfänger und Auftragsverarbeiter
|
||||||
|
|
||||||
|
Die Anwendung, Datenbank, Sitzungen, Sicherungen, der Mailtransport und die selbst betriebene Matomo-Instanz werden auf Infrastruktur der netcup GmbH, Emmy-Noether-Straße 10, 76131 Karlsruhe, Deutschland, innerhalb Deutschlands beziehungsweise der Europäischen Union betrieben. Mit netcup besteht ein Vertrag über Auftragsverarbeitung. Durch die Nutzungsanalyse werden keine Daten an Matomo Cloud übermittelt.
|
||||||
|
|
||||||
|
Weitere Empfänger sind Stripe für Zahlung und Abrechnung, Behörden oder Berater bei gesetzlicher Pflicht beziehungsweise Rechtsverteidigung und die vom Kunden bestimmten Nutzer und Empfänger. Eine Weitergabe für fremde Werbung findet nicht statt.
|
||||||
|
|
||||||
|
## 10. Cookies, Einwilligung und Matomo
|
||||||
|
|
||||||
|
Die Anwendung setzt das Sitzungs-Cookie „kaffeeliste_session“. Es ist für Login, Mandantenzuordnung, Sicherheit und CSRF-Schutz erforderlich, enthält eine zufällige Sitzungskennung, ist für JavaScript nicht lesbar und wird beim Schließen des Browsers beendet. Grundlage für den Zugriff auf das Endgerät ist § 25 Abs. 2 Nr. 2 TDDDG; die anschließende Verarbeitung beruht auf Art. 6 Abs. 1 lit. b und f DSGVO.
|
||||||
|
|
||||||
|
Das Cookie „kaffeeliste_analytics_consent“ speichert für 180 Tage ausschließlich die Version der Abfrage und die Entscheidung „erlaubt“ oder „abgelehnt“. Es enthält keine Benutzerkennung. Es ist erforderlich, um die gewünschte Einstellung umzusetzen und nicht bei jedem Seitenaufruf erneut zu fragen. Grundlage sind § 25 Abs. 2 Nr. 2 TDDDG und, soweit die Einstellung personenbezogen ist, Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist eine nutzerfreundliche und zuverlässige Einwilligungsverwaltung.
|
||||||
|
|
||||||
|
Nur nach einer freiwilligen Einwilligung verwenden wir die selbst betriebene Analysesoftware Matomo, um Aufrufe und Navigationswege auf der öffentlichen Website und in der App auszuwerten. Der Browser sendet die Analysedaten dabei zunächst an die gerade besuchte Kaffeeliste-Domain; unser Server leitet sie an die fest konfigurierte Matomo-Instanz weiter. Die IP-Adresse des Endgeräts wird nicht an Matomo weitergegeben. Verarbeitet werden können Zeitpunkt, aufgerufener Seitenpfad und Seitentitel, zuvor besuchter Kaffeeliste-Seitenpfad, Browsertyp, Betriebssystem, Sprache, Bildschirmauflösung sowie eine zufällige Besucherkennung. URL-Abfragen und Fragmente werden vor der Übermittlung entfernt; dadurch werden insbesondere Verifizierungs-, Passwort-Reset- und Zugriffs-Tokens nicht an Matomo gesendet. Formulareingaben, Kasseninhalte und eine Kaffeeliste-Benutzerkennung werden nicht an Matomo übermittelt. Browser-Funktionserkennung, Performance-Messung, Link-Klick-Tracking, Heatmaps und Sitzungsaufzeichnungen sind nicht aktiviert.
|
||||||
|
|
||||||
|
Nach der Einwilligung setzen wir das Cookie „kaffeeliste_analytics_visitor“ mit einer zufälligen, nicht aus anderen Angaben abgeleiteten Besucherkennung für bis zu 180 Tage. Es ermöglicht Matomo, mehrere Seitenaufrufe desselben Browsers als Navigationsweg zusammenzufassen. Eine konto- oder personenbezogene Nutzer-ID wird nicht verwendet. Rechtsgrundlagen sind § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO. Die Einwilligung ist freiwillig; ihre Ablehnung hat keinen Einfluss auf die Nutzung des Dienstes.
|
||||||
|
|
||||||
|
Die IP-Adresse wird in Matomo vor ihrer dauerhaften Speicherung um mindestens zwei Bytes gekürzt. Roh- und Besuchsdaten werden spätestens nach 180 Tagen gelöscht; ausschließlich verdichtete Berichte werden höchstens 25 Monate aufbewahrt. Es findet kein websiteübergreifendes Tracking, keine Werbung und keine Weitergabe der Analysedaten an Dritte zu deren eigenen Zwecken statt.
|
||||||
|
|
||||||
|
Die Entscheidung kann jederzeit über die ständig erreichbare Schaltfläche „Cookie-Einstellungen“ geändert werden. Bei einem Widerruf wird die künftige Analyse beendet und das Analyse-Besuchercookie sowie gegebenenfalls noch vorhandene ältere Matomo-Cookies werden gelöscht. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Verarbeitung bleibt unberührt.
|
||||||
|
|
||||||
|
Schriften und Gestaltungselemente werden lokal ausgeliefert; beim bloßen Seitenaufruf wird keine Verbindung zu Google Fonts hergestellt.
|
||||||
|
|
||||||
|
## 11. Speicherdauer und Löschung
|
||||||
|
|
||||||
|
- Kundenkonto und operative Mandantendaten: für die Vertragsdauer; bei einer ausdrücklich ausgelösten Mandantenlöschung sofort aus dem Produktivsystem, sonst 30 Tage nach Vertragsende
|
||||||
|
- technische Rate-Limit-Daten: höchstens 24 Stunden
|
||||||
|
- Admin- und Sicherheitsprotokoll: regelmäßig 180 Tage; bei einem konkreten Vorfall bis zu dessen Abschluss und Ablauf einschlägiger Verjährungsfristen
|
||||||
|
- E-Mail-Versandprotokoll: regelmäßig 180 Tage
|
||||||
|
- Matomo-Roh- und Besuchsdaten: höchstens 180 Tage; verdichtete Analyseberichte höchstens 25 Monate
|
||||||
|
- Vertragsannahmen, Kündigungen, Widerrufe und erforderliche Korrespondenz: regelmäßig bis zum Ablauf der zivilrechtlichen Verjährung, grundsätzlich drei Jahre ab Ende des Jahres, bei Rechtsstreit länger
|
||||||
|
- Rechnungen, Zahlungs- und Buchungsbelege des Anbieters: acht Jahre oder eine andere gesetzlich vorgeschriebene Dauer nach § 257 HGB und § 147 AO
|
||||||
|
- Sicherungskopien: rollierend bis zu 14 Tage; danach Überschreibung. Bei Wiederherstellung werden zuvor fällige Löschungen erneut ausgeführt.
|
||||||
|
|
||||||
|
Interne Kaffee- oder Getränkebuchungen des Kunden werden nicht pauschal als steuerliche Belege des Anbieters behandelt. Der Kunde bestimmt im Rahmen seiner Verantwortung eigene Aufbewahrungsfristen und kann den Mandanten löschen. Gesetzlich aufzubewahrende Abrechnungsdaten des Anbieters bleiben getrennt erhalten.
|
||||||
|
|
||||||
|
## 12. Ihre Rechte
|
||||||
|
|
||||||
|
Soweit CTB-IT Verantwortlicher ist, bestehen nach den gesetzlichen Voraussetzungen Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit und Widerspruch. Eine erteilte Einwilligung in die Nutzungsanalyse kann jederzeit über „Cookie-Einstellungen“ mit Wirkung für die Zukunft widerrufen werden.
|
||||||
|
|
||||||
|
Gegen Verarbeitungen nach Art. 6 Abs. 1 lit. f DSGVO können Sie aus Gründen Ihrer besonderen Situation widersprechen. Bei Direktwerbung wäre ein Widerspruch jederzeit ohne Begründung möglich; Direktwerbung ist derzeit nicht vorgesehen.
|
||||||
|
|
||||||
|
Anfragen richten Sie an info@ctb-it.de oder über das Ticketsystem im Impressum. Sie können sich außerdem bei einer Datenschutzaufsichtsbehörde beschweren. Für den Anbieter ist insbesondere die Landesbeauftragte für den Datenschutz Niedersachsen, Prinzenstraße 5, 30159 Hannover, zuständig.
|
||||||
|
|
||||||
|
## 13. Automatisierte Entscheidungen
|
||||||
|
|
||||||
|
Es findet keine ausschließlich automatisierte Entscheidungsfindung einschließlich Profiling statt, die rechtliche Wirkung entfaltet oder Sie ähnlich erheblich beeinträchtigt. Sicherheitslimits können Anfragen kurzfristig technisch blockieren; eine Vertragsentscheidung wird damit nicht getroffen.
|
||||||
|
|
||||||
|
## 14. Änderungen
|
||||||
|
|
||||||
|
Wir aktualisieren diese Erklärung, wenn sich Verarbeitung oder Rechtslage ändern. Bei wesentlichen Änderungen für bestehende Kunden informieren wir über einen geeigneten Kontaktweg.
|
||||||
|
|
||||||
|
Stand: 27. August 2026
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
require_once __DIR__ . '/functions.php';
|
require_once __DIR__ . '/functions.php';
|
||||||
require_once __DIR__ . '/app/rate-limit.php';
|
require_once __DIR__ . '/app/rate-limit.php';
|
||||||
require_once __DIR__ . '/app/totp.php';
|
require_once __DIR__ . '/app/totp.php';
|
||||||
|
require_once __DIR__ . '/app/public-page-views.php';
|
||||||
|
|
||||||
|
app_record_public_page_view('login');
|
||||||
|
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/app/analytics-relay.php';
|
||||||
|
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||||
|
header('Allow: POST');
|
||||||
|
http_response_code(405);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||||
|
if ($contentLength <= 0 || $contentLength > 8192) {
|
||||||
|
http_response_code(413);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawBody = file_get_contents('php://input');
|
||||||
|
$payload = is_string($rawBody) ? json_decode($rawBody, true) : null;
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var array<string, string> $cookies */
|
||||||
|
$cookies = $_COOKIE;
|
||||||
|
$parameters = app_matomo_page_view_parameters($payload, $_SERVER, $cookies);
|
||||||
|
} catch (Throwable) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = app_matomo_forward_page_view($parameters);
|
||||||
|
if (!$result['ok']) {
|
||||||
|
error_log('First-Party-Matomo-Tracking fehlgeschlagen (HTTP ' . $result['status'] . ').');
|
||||||
|
http_response_code(502);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(204);
|
||||||
+5
-1
@@ -1,4 +1,8 @@
|
|||||||
<?php require_once __DIR__ . '/app/legal.php'; ?>
|
<?php
|
||||||
|
require_once __DIR__ . '/app/legal.php';
|
||||||
|
require_once __DIR__ . '/app/public-page-views.php';
|
||||||
|
app_record_public_page_view('prices');
|
||||||
|
?>
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE HTML>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ require_once __DIR__ . '/app/rate-limit.php';
|
|||||||
require_once __DIR__ . '/app/legal.php';
|
require_once __DIR__ . '/app/legal.php';
|
||||||
require_once __DIR__ . '/app/pushover.php';
|
require_once __DIR__ . '/app/pushover.php';
|
||||||
require_once __DIR__ . '/app/spam-guard.php';
|
require_once __DIR__ . '/app/spam-guard.php';
|
||||||
|
require_once __DIR__ . '/app/public-page-views.php';
|
||||||
|
|
||||||
|
app_record_public_page_view('register');
|
||||||
|
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|||||||
@@ -55,31 +55,47 @@ $configuration = app_matomo_configuration();
|
|||||||
$assert('Vollständige Konfiguration wird aktiviert', $configuration !== null);
|
$assert('Vollständige Konfiguration wird aktiviert', $configuration !== null);
|
||||||
$assert('Matomo-Basis-URL wird normalisiert', ($configuration['matomoUrl'] ?? '') === 'https://analytics.example.test/');
|
$assert('Matomo-Basis-URL wird normalisiert', ($configuration['matomoUrl'] ?? '') === 'https://analytics.example.test/');
|
||||||
$assert('Einwilligung gilt 180 Tage', ($configuration['consentMaxAge'] ?? 0) === 15552000);
|
$assert('Einwilligung gilt 180 Tage', ($configuration['consentMaxAge'] ?? 0) === 15552000);
|
||||||
|
$assert('Neue Tracking-Art fragt bestehende Entscheidungen erneut ab', ($configuration['consentVersion'] ?? '') === '2');
|
||||||
|
|
||||||
$head = app_analytics_head_html();
|
$head = app_analytics_head_html();
|
||||||
$body = app_analytics_body_html();
|
$body = app_analytics_body_html();
|
||||||
$assert('Consent-Stylesheet wird lokal geladen', str_contains($head, 'assets/css/analytics-consent.css'));
|
$assert('Consent-Stylesheet wird lokal und filterarm geladen', str_contains($head, 'assets/css/privacy-settings.css'));
|
||||||
$assert('Zustimmen und Ablehnen stehen in derselben Ebene',
|
$assert('Zustimmen und Ablehnen stehen in derselben Ebene',
|
||||||
str_contains($body, 'data-analytics-consent="denied"')
|
str_contains($body, 'data-analytics-consent="denied"')
|
||||||
&& str_contains($body, 'data-analytics-consent="granted"'));
|
&& str_contains($body, 'data-analytics-consent="granted"'));
|
||||||
$assert('Matomo selbst ist im HTML nicht vorab eingebunden',
|
$assert('Matomo selbst ist im HTML nicht vorab eingebunden',
|
||||||
!str_contains($body, 'src="https://analytics.example.test'));
|
!str_contains($body, 'https://analytics.example.test')
|
||||||
|
&& !str_contains($body, '"siteId"'));
|
||||||
$assert('Cookie-Einstellungen bleiben nach der Entscheidung erreichbar',
|
$assert('Cookie-Einstellungen bleiben nach der Entscheidung erreichbar',
|
||||||
str_contains($body, 'analytics-consent-settings'));
|
str_contains($body, 'analytics-consent-settings'));
|
||||||
|
$assert('Browser nutzt den lokalen First-Party-Endpunkt',
|
||||||
|
str_contains($body, '"trackingEndpoint":"nutzungsanalyse.php"')
|
||||||
|
&& str_contains($body, 'assets/js/privacy-settings.js'));
|
||||||
|
|
||||||
$javascript = file_get_contents(APP_ROOT . '/assets/js/analytics-consent.js') ?: '';
|
$javascript = file_get_contents(APP_ROOT . '/assets/js/privacy-settings.js') ?: '';
|
||||||
$assert('Tracker verlangt Tracking-Einwilligung', str_contains($javascript, "['requireConsent']"));
|
$assert('Tracker sendet nur bei erteilter Einwilligung',
|
||||||
$assert('Matomo-Script wird erst dynamisch nach Zustimmung erzeugt',
|
str_contains($javascript, "readDecision() !== 'granted'"));
|
||||||
str_contains($javascript, "document.createElement('script')"));
|
$assert('Browser sendet nur an den lokalen Endpunkt',
|
||||||
|
str_contains($javascript, 'window.fetch(configuration.trackingEndpoint')
|
||||||
|
&& !str_contains($javascript, 'matomo.js')
|
||||||
|
&& !str_contains($javascript, 'matomo.php'));
|
||||||
$assert('Query und Fragment werden aus Analyse-URLs entfernt',
|
$assert('Query und Fragment werden aus Analyse-URLs entfernt',
|
||||||
str_contains($javascript, 'url.origin + url.pathname'));
|
str_contains($javascript, 'url.origin + url.pathname'));
|
||||||
$assert('Zusätzliche Browser- und Linkmessung bleibt deaktiviert',
|
$assert('Keine Kaffeeliste-Benutzerkennung wird gelesen',
|
||||||
str_contains($javascript, "['disableBrowserFeatureDetection']")
|
!str_contains($javascript, 'userId')
|
||||||
&& str_contains($javascript, "['disablePerformanceTracking']")
|
&& !str_contains($javascript, 'sessionStorage')
|
||||||
&& !str_contains($javascript, "['enableLinkTracking']"));
|
&& !str_contains($javascript, 'localStorage'));
|
||||||
$assert('Widerruf stoppt Tracking und löscht Cookies',
|
$assert('Widerruf stoppt Tracking und löscht Analyse-Cookies',
|
||||||
str_contains($javascript, "['forgetConsentGiven']")
|
str_contains($javascript, 'removeAnalysisCookies()')
|
||||||
&& str_contains($javascript, "['deleteCookies']"));
|
&& str_contains($javascript, "name !== visitorCookieName"));
|
||||||
|
|
||||||
|
$relay = file_get_contents(APP_ROOT . '/app/analytics-relay.php') ?: '';
|
||||||
|
$assert('Relay übernimmt keine Client-IP',
|
||||||
|
!str_contains($relay, 'REMOTE_ADDR')
|
||||||
|
&& !str_contains($relay, "'cip'"));
|
||||||
|
$assert('Relay bindet Ziel und Site-ID an die Serverkonfiguration',
|
||||||
|
str_contains($relay, "['idsite'] = \$configuration['siteId']")
|
||||||
|
&& str_contains($relay, "\$configuration['matomoUrl'] . 'matomo.php'"));
|
||||||
|
|
||||||
$publicTemplates = [
|
$publicTemplates = [
|
||||||
'abo-bestellen.php',
|
'abo-bestellen.php',
|
||||||
@@ -113,6 +129,8 @@ $privacy = app_legal_document_text('privacy_notice');
|
|||||||
$assert('Aktuelle Datenschutzerklärung beschreibt Matomo und Widerruf',
|
$assert('Aktuelle Datenschutzerklärung beschreibt Matomo und Widerruf',
|
||||||
str_contains($privacy, 'Matomo')
|
str_contains($privacy, 'Matomo')
|
||||||
&& str_contains($privacy, 'Cookie-Einstellungen')
|
&& str_contains($privacy, 'Cookie-Einstellungen')
|
||||||
|
&& str_contains($privacy, 'kaffeeliste_analytics_visitor')
|
||||||
|
&& str_contains($privacy, 'nicht an Matomo weitergegeben')
|
||||||
&& str_contains($privacy, 'Verifizierungs-, Passwort-Reset- und Zugriffs-Tokens'));
|
&& str_contains($privacy, 'Verifizierungs-, Passwort-Reset- und Zugriffs-Tokens'));
|
||||||
|
|
||||||
foreach ($previousEnvironment as $name => $value) {
|
foreach ($previousEnvironment as $name => $value) {
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../app/analytics-relay.php';
|
||||||
|
|
||||||
|
$failures = 0;
|
||||||
|
$assert = static function (string $label, bool $condition) use (&$failures): void {
|
||||||
|
if ($condition) {
|
||||||
|
echo "OK: {$label}\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$failures++;
|
||||||
|
echo "FEHLER: {$label}\n";
|
||||||
|
};
|
||||||
|
|
||||||
|
$environmentNames = [
|
||||||
|
'APP_ENV',
|
||||||
|
'APP_HOST',
|
||||||
|
'APP_MARKETING_URL',
|
||||||
|
'MATOMO_URL',
|
||||||
|
'MATOMO_SITE_ID',
|
||||||
|
'MATOMO_CONSENT_COOKIE_DOMAIN',
|
||||||
|
'MATOMO_PRIVACY_CONFIRMED',
|
||||||
|
];
|
||||||
|
$previousEnvironment = [];
|
||||||
|
foreach ($environmentNames as $name) {
|
||||||
|
$previousEnvironment[$name] = getenv($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
putenv('APP_ENV=dev');
|
||||||
|
putenv('APP_HOST=app.example.test');
|
||||||
|
putenv('APP_MARKETING_URL=http://example.test:8765');
|
||||||
|
putenv('MATOMO_URL=http://analytics.example.test/');
|
||||||
|
putenv('MATOMO_SITE_ID=7');
|
||||||
|
putenv('MATOMO_CONSENT_COOKIE_DOMAIN=.example.test');
|
||||||
|
putenv('MATOMO_PRIVACY_CONFIRMED=1');
|
||||||
|
|
||||||
|
$visitorId = '0123456789abcdef';
|
||||||
|
$cookies = [
|
||||||
|
APP_ANALYTICS_CONSENT_COOKIE => APP_ANALYTICS_CONSENT_VERSION . ':granted',
|
||||||
|
APP_ANALYTICS_VISITOR_COOKIE => $visitorId,
|
||||||
|
];
|
||||||
|
$server = [
|
||||||
|
'HTTP_HOST' => 'example.test:8765',
|
||||||
|
'HTTP_ORIGIN' => 'http://example.test:8765',
|
||||||
|
'HTTP_SEC_FETCH_SITE' => 'same-origin',
|
||||||
|
'SERVER_PORT' => '8765',
|
||||||
|
'HTTP_USER_AGENT' => 'Relay-Testbrowser/1.0',
|
||||||
|
'REMOTE_ADDR' => '203.0.113.42',
|
||||||
|
];
|
||||||
|
$payload = [
|
||||||
|
'visitorId' => $visitorId,
|
||||||
|
'pageUrl' => 'http://example.test:8765/passwort-zuruecksetzen.php?token=GEHEIM#formular',
|
||||||
|
'pageTitle' => 'Kaffeeliste Passwort zurücksetzen',
|
||||||
|
'referrerUrl' => 'http://app.example.test/login.php?source=GEHEIM',
|
||||||
|
'resolution' => '1440x900',
|
||||||
|
'language' => 'de-DE',
|
||||||
|
'h' => 12,
|
||||||
|
'm' => 34,
|
||||||
|
's' => 56,
|
||||||
|
];
|
||||||
|
|
||||||
|
$parameters = app_matomo_page_view_parameters($payload, $server, $cookies);
|
||||||
|
$assert('Seiten-URL verliert Query und Fragment',
|
||||||
|
($parameters['url'] ?? '') === 'http://example.test:8765/passwort-zuruecksetzen.php');
|
||||||
|
$assert('Interner Referrer verliert Query und Fragment',
|
||||||
|
($parameters['urlref'] ?? '') === 'http://app.example.test/login.php');
|
||||||
|
$assert('Zufällige Besucherkennung wird unverändert genutzt', ($parameters['_id'] ?? '') === $visitorId);
|
||||||
|
$assert('Browserdaten werden auf erlaubte Angaben begrenzt',
|
||||||
|
($parameters['res'] ?? '') === '1440x900'
|
||||||
|
&& ($parameters['lang'] ?? '') === 'de-DE'
|
||||||
|
&& ($parameters['ua'] ?? '') === 'Relay-Testbrowser/1.0');
|
||||||
|
$assert('Client-IP wird nicht an Matomo übergeben',
|
||||||
|
!array_key_exists('cip', $parameters)
|
||||||
|
&& !in_array('203.0.113.42', $parameters, true));
|
||||||
|
|
||||||
|
$capturedUrl = '';
|
||||||
|
$capturedParameters = [];
|
||||||
|
$forwardResult = app_matomo_forward_page_view(
|
||||||
|
$parameters,
|
||||||
|
static function (string $url, array $values) use (&$capturedUrl, &$capturedParameters): array {
|
||||||
|
$capturedUrl = $url;
|
||||||
|
$capturedParameters = $values;
|
||||||
|
return ['ok' => true, 'status' => 204, 'error' => null];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
$assert('Relay verwendet den fest konfigurierten Matomo-Endpunkt',
|
||||||
|
$forwardResult['ok']
|
||||||
|
&& $capturedUrl === 'http://analytics.example.test/matomo.php'
|
||||||
|
&& ($capturedParameters['idsite'] ?? '') === '7');
|
||||||
|
|
||||||
|
$mustReject = static function (array $changedPayload, array $changedServer, array $changedCookies): bool {
|
||||||
|
try {
|
||||||
|
app_matomo_page_view_parameters($changedPayload, $changedServer, $changedCookies);
|
||||||
|
return false;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$deniedCookies = $cookies;
|
||||||
|
$deniedCookies[APP_ANALYTICS_CONSENT_COOKIE] = APP_ANALYTICS_CONSENT_VERSION . ':denied';
|
||||||
|
$assert('Ohne Einwilligung wird der Aufruf verworfen', $mustReject($payload, $server, $deniedCookies));
|
||||||
|
|
||||||
|
$foreignServer = $server;
|
||||||
|
$foreignServer['HTTP_ORIGIN'] = 'https://evil.example';
|
||||||
|
$foreignServer['HTTP_SEC_FETCH_SITE'] = 'cross-site';
|
||||||
|
$assert('Fremder Ursprung wird verworfen', $mustReject($payload, $foreignServer, $cookies));
|
||||||
|
|
||||||
|
$foreignPage = $payload;
|
||||||
|
$foreignPage['pageUrl'] = 'https://evil.example/gestohlen?token=1';
|
||||||
|
$assert('Fremde Seiten-URL wird verworfen', $mustReject($foreignPage, $server, $cookies));
|
||||||
|
|
||||||
|
$wrongVisitor = $payload;
|
||||||
|
$wrongVisitor['visitorId'] = 'fedcba9876543210';
|
||||||
|
$assert('Nicht zum Cookie passende Besucherkennung wird verworfen', $mustReject($wrongVisitor, $server, $cookies));
|
||||||
|
|
||||||
|
$relaySource = file_get_contents(APP_ROOT . '/app/analytics-relay.php') ?: '';
|
||||||
|
$assert('Relay-Code liest die Client-IP nicht', !str_contains($relaySource, "['REMOTE_ADDR']"));
|
||||||
|
|
||||||
|
foreach ($previousEnvironment as $name => $value) {
|
||||||
|
$value === false ? putenv($name) : putenv($name . '=' . $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failures > 0) {
|
||||||
|
echo "\n{$failures} Relay-Prüfung(en) fehlgeschlagen.\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nFirst-Party-Matomo-Relay: alle Prüfungen bestanden.\n";
|
||||||
@@ -65,8 +65,8 @@ $assertSame(
|
|||||||
);
|
);
|
||||||
|
|
||||||
$migrationFiles = glob(__DIR__ . '/../database/migrations/*.sql') ?: [];
|
$migrationFiles = glob(__DIR__ . '/../database/migrations/*.sql') ?: [];
|
||||||
if (count($migrationFiles) !== 30) {
|
if (count($migrationFiles) !== 32) {
|
||||||
fwrite(STDERR, 'FEHLER: Erwartet wurden 30 Migrationen, gefunden wurden ' . count($migrationFiles) . ".\n");
|
fwrite(STDERR, 'FEHLER: Erwartet wurden 32 Migrationen, gefunden wurden ' . count($migrationFiles) . ".\n");
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,4 +93,4 @@ if (preg_match('/(?<![A-Za-z0-9_])fk_[A-Za-z0-9_]+(?![A-Za-z0-9_])/i', $prefixed
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "DB-Prefix-Prüfung bestanden: Produktion ist fail-closed; Laufzeit-SQL und alle 30 Migrationen werden getrennt umgeschrieben.\n";
|
echo "DB-Prefix-Prüfung bestanden: Produktion ist fail-closed; Laufzeit-SQL und alle 32 Migrationen werden getrennt umgeschrieben.\n";
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ if ($baseUrl !== '' && (!str_starts_with($baseUrl, 'https://') || parse_url($bas
|
|||||||
foreach (app_matomo_configuration_errors() as $matomoError) {
|
foreach (app_matomo_configuration_errors() as $matomoError) {
|
||||||
$errors[] = $matomoError;
|
$errors[] = $matomoError;
|
||||||
}
|
}
|
||||||
|
foreach (['nutzungsanalyse.php', 'app/analytics-relay.php', 'assets/js/privacy-settings.js', 'assets/css/privacy-settings.css'] as $analyticsFile) {
|
||||||
|
if (!is_file(APP_ROOT . '/' . $analyticsFile)) {
|
||||||
|
$errors[] = "First-Party-Analyse-Datei {$analyticsFile} fehlt.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$dbTablePrefix = app_db_table_prefix();
|
$dbTablePrefix = app_db_table_prefix();
|
||||||
@@ -78,7 +83,7 @@ if (preg_match('~@import\s+url\(["\']?https?://|url\(["\']?https?://~i', $mainCs
|
|||||||
}
|
}
|
||||||
|
|
||||||
$privacyText = app_legal_document_text('privacy_notice');
|
$privacyText = app_legal_document_text('privacy_notice');
|
||||||
foreach (['Matomo', 'kaffeeliste_analytics_consent', 'Cookie-Einstellungen'] as $privacyRequirement) {
|
foreach (['Matomo', 'kaffeeliste_analytics_consent', 'kaffeeliste_analytics_visitor', 'Cookie-Einstellungen', 'anonyme Aufrufzahlen'] as $privacyRequirement) {
|
||||||
if (!str_contains($privacyText, $privacyRequirement)) {
|
if (!str_contains($privacyText, $privacyRequirement)) {
|
||||||
$errors[] = "Datenschutzerklärung nennt {$privacyRequirement} nicht.";
|
$errors[] = "Datenschutzerklärung nennt {$privacyRequirement} nicht.";
|
||||||
}
|
}
|
||||||
@@ -86,7 +91,7 @@ foreach (['Matomo', 'kaffeeliste_analytics_consent', 'Cookie-Einstellungen'] as
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
foreach (['legal_acceptances', 'legal_requests', 'tenant_billing', 'rate_limit_attempts'] as $table) {
|
foreach (['legal_acceptances', 'legal_requests', 'tenant_billing', 'rate_limit_attempts', 'public_page_views'] as $table) {
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
'SELECT 1 FROM INFORMATION_SCHEMA.TABLES
|
'SELECT 1 FROM INFORMATION_SCHEMA.TABLES
|
||||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
|
||||||
@@ -97,9 +102,9 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM schema_migrations WHERE version = ?');
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM schema_migrations WHERE version = ?');
|
||||||
$stmt->execute(['0030_contract_termination.sql']);
|
$stmt->execute(['0032_public_page_views.sql']);
|
||||||
if ((int)$stmt->fetchColumn() !== 1) {
|
if ((int)$stmt->fetchColumn() !== 1) {
|
||||||
$errors[] = 'Migration 0030_contract_termination.sql ist nicht angewendet.';
|
$errors[] = 'Migration 0032_public_page_views.sql ist nicht angewendet.';
|
||||||
}
|
}
|
||||||
$unknownTenants = (int)$pdo->query("SELECT COUNT(*) FROM tenants WHERE customer_type = 'unknown'")->fetchColumn();
|
$unknownTenants = (int)$pdo->query("SELECT COUNT(*) FROM tenants WHERE customer_type = 'unknown'")->fetchColumn();
|
||||||
if ($unknownTenants > 0) {
|
if ($unknownTenants > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/dev-db.php';
|
||||||
|
require_once __DIR__ . '/../app/public-page-views.php';
|
||||||
|
require_once __DIR__ . '/../app/legal.php';
|
||||||
|
|
||||||
|
$pdo = dev_pdo();
|
||||||
|
dev_apply_migrations($pdo);
|
||||||
|
|
||||||
|
$failures = 0;
|
||||||
|
$assert = static function (string $label, bool $condition) use (&$failures): void {
|
||||||
|
if ($condition) {
|
||||||
|
echo "OK: {$label}\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$failures++;
|
||||||
|
echo "FEHLER: {$label}\n";
|
||||||
|
};
|
||||||
|
|
||||||
|
$previousMethod = $_SERVER['REQUEST_METHOD'] ?? null;
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare('SELECT view_count FROM public_page_views WHERE view_date = CURRENT_DATE AND page_key = ?');
|
||||||
|
$stmt->execute(['landing']);
|
||||||
|
$before = (int)($stmt->fetchColumn() ?: 0);
|
||||||
|
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||||
|
app_record_public_page_view('landing', $pdo);
|
||||||
|
$stmt->execute(['landing']);
|
||||||
|
$afterGet = (int)($stmt->fetchColumn() ?: 0);
|
||||||
|
$assert('Ein GET erhöht den aggregierten Tageszähler genau einmal', $afterGet === $before + 1);
|
||||||
|
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
app_record_public_page_view('landing', $pdo);
|
||||||
|
$stmt->execute(['landing']);
|
||||||
|
$afterPost = (int)($stmt->fetchColumn() ?: 0);
|
||||||
|
$assert('Ein POST wird nicht als Seitenaufruf gezählt', $afterPost === $afterGet);
|
||||||
|
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||||
|
try {
|
||||||
|
app_record_public_page_view('token-aus-einer-url', $pdo);
|
||||||
|
$assert('Unbekannte Seitenschlüssel werden abgewiesen', false);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
$assert('Unbekannte Seitenschlüssel werden abgewiesen', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = app_public_page_view_summary($pdo);
|
||||||
|
$landingRows = array_values(array_filter(
|
||||||
|
$summary['pages'],
|
||||||
|
static fn(array $row): bool => $row['page_key'] === 'landing'
|
||||||
|
));
|
||||||
|
$assert('Back-Office-Zusammenfassung enthält den aktuellen Zähler',
|
||||||
|
$summary['totals']['today'] >= $afterGet
|
||||||
|
&& count($landingRows) === 1
|
||||||
|
&& $landingRows[0]['today'] === $afterGet);
|
||||||
|
|
||||||
|
$columns = $pdo->query('SHOW COLUMNS FROM public_page_views')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
sort($columns);
|
||||||
|
$assert('Statistiktabelle enthält nur Datum, Seitenschlüssel und Anzahl',
|
||||||
|
$columns === ['page_key', 'view_count', 'view_date']);
|
||||||
|
|
||||||
|
$counterSource = file_get_contents(__DIR__ . '/../app/public-page-views.php') ?: '';
|
||||||
|
$assert('Zähler liest keine IP-, User-Agent- oder URL-Daten',
|
||||||
|
!str_contains($counterSource, 'REMOTE_ADDR')
|
||||||
|
&& !str_contains($counterSource, 'HTTP_USER_AGENT')
|
||||||
|
&& !str_contains($counterSource, 'REQUEST_URI'));
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'landing.php' => 'landing',
|
||||||
|
'preise.php' => 'prices',
|
||||||
|
'register.php' => 'register',
|
||||||
|
'login.php' => 'login',
|
||||||
|
'datenschutz.php' => 'privacy',
|
||||||
|
'impressum.php' => 'imprint',
|
||||||
|
'agb.php' => 'terms',
|
||||||
|
'avv.php' => 'dpa',
|
||||||
|
'widerruf.php' => 'withdrawal_information',
|
||||||
|
] as $template => $pageKey) {
|
||||||
|
$templateSource = file_get_contents(APP_ROOT . '/' . $template) ?: '';
|
||||||
|
$assert("{$template} zählt den festen Seitenschlüssel {$pageKey}",
|
||||||
|
str_contains($templateSource, "app_record_public_page_view('{$pageKey}')"));
|
||||||
|
}
|
||||||
|
|
||||||
|
$privacy = app_legal_document_text('privacy_notice');
|
||||||
|
$assert('Datenschutzerklärung grenzt den anonymen Zähler von Matomo ab',
|
||||||
|
str_contains($privacy, 'anonyme Aufrufzahlen')
|
||||||
|
&& str_contains($privacy, 'weder die Ermittlung eindeutiger Besucher noch die Bildung von Nutzungsprofilen'));
|
||||||
|
} finally {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
if ($previousMethod === null) {
|
||||||
|
unset($_SERVER['REQUEST_METHOD']);
|
||||||
|
} else {
|
||||||
|
$_SERVER['REQUEST_METHOD'] = $previousMethod;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failures > 0) {
|
||||||
|
echo "\n{$failures} Seitenzähler-Prüfung(en) fehlgeschlagen.\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nAnonymer Seitenzähler: alle Prüfungen bestanden.\n";
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/app/legal.php';
|
require_once __DIR__ . '/app/legal.php';
|
||||||
|
require_once __DIR__ . '/app/public-page-views.php';
|
||||||
|
app_record_public_page_view('withdrawal_information');
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE HTML>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
|
|||||||
Reference in New Issue
Block a user