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'); } }