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,
|
||||
];
|
||||
}
|
||||
+21
-6
@@ -5,7 +5,8 @@ declare(strict_types=1);
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
{
|
||||
@@ -90,6 +91,7 @@ function app_matomo_configuration(): ?array
|
||||
'matomoUrl' => rtrim($url, '/') . '/',
|
||||
'siteId' => $siteId,
|
||||
'consentCookie' => APP_ANALYTICS_CONSENT_COOKIE,
|
||||
'visitorCookie' => APP_ANALYTICS_VISITOR_COOKIE,
|
||||
'consentVersion' => APP_ANALYTICS_CONSENT_VERSION,
|
||||
'consentMaxAge' => APP_ANALYTICS_CONSENT_MAX_AGE,
|
||||
'cookieDomain' => trim((string)app_env('MATOMO_CONSENT_COOKIE_DOMAIN', '')),
|
||||
@@ -103,7 +105,7 @@ function app_analytics_head_html(): string
|
||||
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
|
||||
@@ -113,8 +115,21 @@ function app_analytics_body_html(): string
|
||||
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(
|
||||
$configuration,
|
||||
$browserConfiguration,
|
||||
JSON_UNESCAPED_SLASHES
|
||||
| JSON_UNESCAPED_UNICODE
|
||||
| JSON_HEX_TAG
|
||||
@@ -131,7 +146,7 @@ function app_analytics_body_html(): string
|
||||
<div class="analytics-consent__inner">
|
||||
<div class="analytics-consent__copy">
|
||||
<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 class="analytics-consent__actions">
|
||||
<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>
|
||||
HTML
|
||||
. "\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';
|
||||
|
||||
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_WITHDRAWAL_VERSION = '2026-08-22';
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ function app_database_table_names(): array
|
||||
'payment_import_rows',
|
||||
'stripe_webhook_events',
|
||||
'totp_recovery_codes',
|
||||
'public_page_views',
|
||||
'rate_limit_attempts',
|
||||
'tenant_memberships',
|
||||
'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,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user