diff --git a/packages/vitec/Classes/Command/ClientErrorCleanupCommand.php b/packages/vitec/Classes/Command/ClientErrorCleanupCommand.php new file mode 100644 index 0000000..f8e6f38 --- /dev/null +++ b/packages/vitec/Classes/Command/ClientErrorCleanupCommand.php @@ -0,0 +1,53 @@ +addOption('days', 'd', InputOption::VALUE_REQUIRED, 'Keep entries younger than this many days', (string)self::DEFAULT_DAYS); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $days = (int)$input->getOption('days'); + if ($days < 1) { + $output->writeln('--days must be at least 1'); + return Command::INVALID; + } + $cutoff = time() - $days * 86400; + $conn = $this->connectionPool->getConnectionForTable(self::TABLE); + $deleted = $conn->executeStatement('DELETE FROM ' . self::TABLE . ' WHERE crdate < ?', [$cutoff]); + $remaining = (int)$conn->executeQuery('SELECT COUNT(*) FROM ' . self::TABLE)->fetchOne(); + $output->writeln(sprintf( + 'deleted %d client error report(s) older than %d days (before %s), %d remaining', + $deleted, + $days, + date('Y-m-d H:i', $cutoff), + $remaining + )); + return Command::SUCCESS; + } +} diff --git a/packages/vitec/Classes/Controller/Backend/SsrController.php b/packages/vitec/Classes/Controller/Backend/SsrController.php index 485ba42..3c8ff6c 100644 --- a/packages/vitec/Classes/Controller/Backend/SsrController.php +++ b/packages/vitec/Classes/Controller/Backend/SsrController.php @@ -11,6 +11,7 @@ use TYPO3\CMS\Backend\Attribute\AsController; use TYPO3\CMS\Backend\Routing\UriBuilder; use TYPO3\CMS\Backend\Template\ModuleTemplateFactory; use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Http\JsonResponse; use TYPO3\CMS\Core\Http\RedirectResponse; @@ -73,10 +74,51 @@ final class SsrController 'healthError' => $healthError, 'purgeLog' => $this->rows('tx_vitec_ssrpurgelog', 30), 'healthLog' => $this->rows('tx_vitec_ssrhealthlog', 10), + 'clientErrors' => $this->clientErrors(50), + 'clientErrorCount24h' => $this->clientErrorCount24h(), ]); return $view->renderResponse('Ssr/Index'); } + /** + * Client error reports of the last 24 hours (POST /api/vitec/client-error, + * ClientErrorMiddleware), newest first. Rows carry a "host" (URL without + * scheme/query) and a shortened user agent for the table; the full + * values stay in the row for the details toggle. + */ + private function clientErrors(int $limit): array + { + try { + $qb = $this->connectionPool->getQueryBuilderForTable('tx_vitec_clienterrorlog'); + $rows = $qb->select('*')->from('tx_vitec_clienterrorlog') + ->where($qb->expr()->gt('crdate', $qb->createNamedParameter(time() - 86400, Connection::PARAM_INT))) + ->orderBy('uid', 'DESC')->setMaxResults($limit) + ->executeQuery()->fetchAllAssociative(); + } catch (\Throwable) { + return []; + } + foreach ($rows as &$row) { + $path = (string)(parse_url((string)$row['url'], PHP_URL_PATH) ?? ''); + $row['path'] = $path !== '' ? $path : (string)$row['url']; + $ua = (string)$row['user_agent']; + $row['ua_short'] = mb_strlen($ua) > 60 ? mb_substr($ua, 0, 57) . '...' : $ua; + $row['is_chunk_reload'] = str_contains(strtolower((string)$row['message']), 'chunk reload'); + } + unset($row); + return $rows; + } + + private function clientErrorCount24h(): int + { + try { + return (int)$this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog') + ->executeQuery('SELECT COUNT(*) FROM tx_vitec_clienterrorlog WHERE crdate > ?', [time() - 86400]) + ->fetchOne(); + } catch (\Throwable) { + return 0; + } + } + public function purgeAction(ServerRequestInterface $request): ResponseInterface { $body = (array)$request->getParsedBody(); diff --git a/packages/vitec/Classes/Middleware/ClientErrorMiddleware.php b/packages/vitec/Classes/Middleware/ClientErrorMiddleware.php new file mode 100644 index 0000000..dcc2365 --- /dev/null +++ b/packages/vitec/Classes/Middleware/ClientErrorMiddleware.php @@ -0,0 +1,184 @@ + 204 No Content + * + * Guards, all answered with a 4xx and no body: + * 405 method other than POST + * 413 body larger than 8 KB (Content-Length or actual bytes) + * 400 not a JSON object, or "message" missing/empty + * 429 more than 30 reports per minute from one IP (sliding window + * via TYPO3's rate limiter, cache "ratelimiter") + * + * Entries land in tx_vitec_clienterrorlog with the IP anonymized (IPv4 + * last octet zeroed, IPv6 cut to /48); the full IP is only the key of + * the rate limiter and is never stored. No session, no cookies - a + * navigator.sendBeacon() call must get through unchanged. Rows older + * than 30 days are removed by vitec:client-error-cleanup. + */ +final class ClientErrorMiddleware implements MiddlewareInterface +{ + private const PATH = '/api/vitec/client-error'; + private const MAX_BODY_BYTES = 8192; + private const LIMIT_PER_MINUTE = 30; + private const TABLE = 'tx_vitec_clienterrorlog'; + + public function __construct( + private readonly ConnectionPool $connectionPool, + private readonly RateLimiterFactory $rateLimiterFactory, + ) {} + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $path = rtrim($request->getUri()->getPath(), '/'); + if ($path !== self::PATH) { + return $handler->handle($request); + } + + $method = strtoupper($request->getMethod()); + if ($method === 'OPTIONS') { + return $this->empty(204)->withHeader('Allow', 'POST, OPTIONS'); + } + if ($method !== 'POST') { + return $this->empty(405)->withHeader('Allow', 'POST, OPTIONS'); + } + + $declared = (int)$request->getHeaderLine('Content-Length'); + if ($declared > self::MAX_BODY_BYTES) { + return $this->empty(413); + } + + // Rate limit first, so a flood of oversized or broken bodies is + // cheap to refuse as well. + $ip = ''; + try { + $normalized = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request); + $ip = (string)$normalized->getRemoteAddress(); + $limiter = $this->rateLimiterFactory->createLimiter([ + 'id' => 'vitec-client-error', + 'policy' => 'sliding_window', + 'limit' => self::LIMIT_PER_MINUTE, + 'interval' => '1 minute', + ], $ip); + $limit = $limiter->consume(1); + if (!$limit->isAccepted()) { + $retry = max(1, $limit->getRetryAfter()->getTimestamp() - time()); + return $this->empty(429)->withHeader('Retry-After', (string)$retry); + } + } catch (\Throwable) { + // A broken limiter must not take the endpoint down; the + // body limit and the 30-day cleanup still bound the damage. + } + + $body = $request->getBody(); + if ($body->isSeekable()) { + $body->rewind(); + } + $raw = $body->read(self::MAX_BODY_BYTES + 1); + if (strlen($raw) > self::MAX_BODY_BYTES) { + return $this->empty(413); + } + + try { + $data = json_decode($raw, true, 32, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $this->empty(400); + } + if (!\is_array($data) || array_is_list($data)) { + return $this->empty(400); + } + $message = trim($this->str($data['message'] ?? '')); + if ($message === '') { + return $this->empty(400); + } + + $row = [ + 'pid' => 0, + 'crdate' => time(), + 'url' => $this->cut($this->str($data['url'] ?? ''), 1024), + 'message' => $this->cut($message, 2000), + 'stack' => $this->cut($this->str($data['stack'] ?? ''), 6000), + 'release' => $this->cut($this->str($data['release'] ?? ''), 100), + 'user_agent' => $this->cut($this->str($data['userAgent'] ?? $request->getHeaderLine('User-Agent')), 500), + 'ip' => self::anonymizeIp($ip), + ]; + try { + $this->connectionPool->getConnectionForTable(self::TABLE)->insert(self::TABLE, $row); + } catch (\Throwable) { + // Table missing (extension:setup not run yet) or DB hiccup: + // the client gets its 204 anyway - a reporting endpoint must + // never turn into a second error in the browser console. + } + + return $this->empty(204); + } + + /** + * IPv4: last octet zeroed (192.0.2.0). IPv6: first three groups + * kept (2001:db8:1::). Anything else: empty. + */ + public static function anonymizeIp(string $ip): string + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + $parts = explode('.', $ip); + $parts[3] = '0'; + return implode('.', $parts); + } + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + $packed = inet_pton($ip); + if ($packed === false) { + return ''; + } + $hex = bin2hex($packed); + $groups = []; + for ($i = 0; $i < 3; $i++) { + $groups[] = ltrim(substr($hex, $i * 4, 4), '0') ?: '0'; + } + return implode(':', $groups) . '::'; + } + return ''; + } + + private function str(mixed $value): string + { + if (\is_string($value)) { + return $value; + } + if (\is_int($value) || \is_float($value) || \is_bool($value)) { + return (string)$value; + } + return ''; + } + + private function cut(string $value, int $max): string + { + $value = str_replace("\0", '', $value); + return mb_strlen($value) > $max ? mb_substr($value, 0, $max) : $value; + } + + private function empty(int $status): ResponseInterface + { + return (new Response()) + ->withStatus($status) + ->withHeader('Cache-Control', 'no-store'); + } +} diff --git a/packages/vitec/Classes/Widgets/SsrStatsWidget.php b/packages/vitec/Classes/Widgets/SsrStatsWidget.php index 3957fa0..e8f3f6c 100644 --- a/packages/vitec/Classes/Widgets/SsrStatsWidget.php +++ b/packages/vitec/Classes/Widgets/SsrStatsWidget.php @@ -37,6 +37,11 @@ final class SsrStatsWidget implements WidgetInterface $avgMs = $this->scalar('SELECT ROUND(AVG(response_ms)) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d1 . ' AND ok = 1'); $maxCache = $this->scalar('SELECT MAX(cache_mb) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d7); + // Browser-side errors reported via POST /api/vitec/client-error + // (ClientErrorMiddleware); details in the VITEC SSR module. + $clientErrors24h = $this->count('tx_vitec_clienterrorlog', 'crdate > ' . $d1); + $clientErrors7d = $this->count('tx_vitec_clienterrorlog', 'crdate > ' . $d7); + $rows = [ ['Purges (24 h / 7 days)', $purges24h . ' / ' . $purges7d], ['of which full purges (7 d)', (string)$full7d], @@ -47,6 +52,9 @@ final class SsrStatsWidget implements WidgetInterface . ' (' . $ok24h . '/' . $samples24h . ' samples)'], ['Avg. response time (24 h)', $avgMs !== null ? $avgMs . ' ms' : '-'], ['Max. cache size (7 d)', $maxCache !== null ? $maxCache . ' MB' : '-'], + ['Client errors reported (24 h / 7 d)', ($clientErrors24h > 0 + ? '' . $clientErrors24h . '' + : '0') . ' / ' . $clientErrors7d], ]; $html = ''; diff --git a/packages/vitec/Configuration/RequestMiddlewares.php b/packages/vitec/Configuration/RequestMiddlewares.php index 1a70cbb..c4f989f 100755 --- a/packages/vitec/Configuration/RequestMiddlewares.php +++ b/packages/vitec/Configuration/RequestMiddlewares.php @@ -56,6 +56,22 @@ return [ 'typo3/cms-core/normalized-params-attribute', ], ], + // Client error reports from the React frontend: + // POST /api/vitec/client-error -> tx_vitec_clienterrorlog, 204 + // (0072-FE section 4 / 0077-BE). Answers before site resolution, + // no session, rate-limited per IP, 8 KB body limit. Placed after + // the /_json strip so the prefixed form reaches it as well. + 'vitec/client-error' => [ + 'target' => \Evomedien\Vitec\Middleware\ClientErrorMiddleware::class, + 'after' => [ + 'typo3/cms-core/normalized-params-attribute', + 'vitec/json-prefix-rewrite', + ], + 'before' => [ + 'typo3/cms-frontend/site', + 'vitec/form-submission', + ], + ], // Outer response filter: Cache-Control: no-cache + ETag/304 for // /_json (tickets 0011-FE / 0055-BE). 'vitec/json-cache-headers' => [ diff --git a/packages/vitec/Resources/Private/Templates/Ssr/Index.html b/packages/vitec/Resources/Private/Templates/Ssr/Index.html index 8655773..7d25656 100644 --- a/packages/vitec/Resources/Private/Templates/Ssr/Index.html +++ b/packages/vitec/Resources/Private/Templates/Ssr/Index.html @@ -140,4 +140,42 @@
+

Client errors (last 24 h — {clientErrorCount24h} reports, newest 50 shown)

+

+ Browser-side failures reported by the React frontend via POST /api/vitec/client-error + (JS errors, unhandled promise rejections, element error boundaries and "chunk reload" after a deploy). + Limited to 30 reports per minute and IP, 8 KB per report; IPs are stored anonymized. + Kept for 30 days (cron vitec:client-error-cleanup). +

+ + + + + + + + + + + + + + + + + +
TimeReleaseMessagePageBrowserIP
@{row.crdate}{row.release} + + chunk reload + error + + {row.message} + +
+ stack +
{row.stack}
+
+
+
{row.path}{row.ua_short}{row.ip}
No client errors reported in the last 24 hours.
+ diff --git a/packages/vitec/ext_tables.sql b/packages/vitec/ext_tables.sql index e53e1d7..da33ef7 100755 --- a/packages/vitec/ext_tables.sql +++ b/packages/vitec/ext_tables.sql @@ -499,3 +499,22 @@ CREATE TABLE tx_vitec_ssrhealthlog ( PRIMARY KEY (uid), KEY crdate (crdate) ); + +# +# Client error reports from the React frontend +# (POST /api/vitec/client-error, ClientErrorMiddleware; 30-day retention +# via vitec:client-error-cleanup; shown in the VITEC SSR module) +# +CREATE TABLE tx_vitec_clienterrorlog ( + uid int(11) NOT NULL auto_increment, + pid int(11) DEFAULT '0' NOT NULL, + crdate int(11) DEFAULT '0' NOT NULL, + url varchar(1024) DEFAULT '' NOT NULL, + message text, + stack text, + release varchar(100) DEFAULT '' NOT NULL, + user_agent varchar(500) DEFAULT '' NOT NULL, + ip varchar(64) DEFAULT '' NOT NULL, + PRIMARY KEY (uid), + KEY crdate (crdate) +);