true`. private ?\Closure $purgeGate = null, ) { $this->purgeGate ??= static fn (): bool => mt_rand(1, 200) === 1; } public function handle(Request $req, int $now, string $clientIp): Response { $payload = $req->json(); $deviceId = (string)($payload['device_id'] ?? ''); $window = intdiv($now, 60); // Pre-auth IP limit (cheap, anti-flood): guards the endpoint before any // HMAC/device-secret work, so an unauthenticated attacker can't burn // a real device's own quota (see per-device limit below, moved post-auth). if ($clientIp !== '' && $this->rateLimiter->hit('ip:' . $clientIp, $window) > $this->cfg->ingestIpRateLimitPerMin()) { return Response::json(['error' => 'rate_limited'], 429); } $nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? ''; $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; if ($secret === null || $nonce === '' || $ts === '' || $sig === '') { return Response::json(['error' => 'unauthorized'], 401); } if (!Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) { return Response::json(['error' => 'unauthorized'], 401); } if (abs($now - (int)(float)$ts) > $this->cfg->timestampWindow()) { return Response::json(['error' => 'unauthorized'], 401); } if ($this->nonces->seen($nonce)) { return Response::json(['error' => 'unauthorized'], 401); } $this->nonces->remember($nonce, $now + $this->cfg->nonceTtl()); // Post-auth per-device limit: only authenticated (HMAC-verified) requests // count toward a device's quota, so an attacker guessing device_id can no // longer 429-lock a real camera. if ($this->rateLimiter->hit($deviceId, $window) > $this->cfg->rateLimitPerMin()) { return Response::json(['error' => 'rate_limited'], 429); } $v = EventValidator::validate($payload); if (!$v['ok']) return Response::json(['error' => 'invalid', 'details' => $v['errors']], 400); $eventId = $this->events->insert($v['clean']); foreach (($payload['snapshots'] ?? []) as $b64) { $img = ImageProcessor::reencode((string)$b64); if (!$img['ok']) continue; $path = rtrim($this->cfg->snapshotDir(), '/\\') . '/' . $img['uuid'] . '.jpg'; if (file_put_contents($path, $img['bytes']) === false) continue; $this->snapshots->add($eventId, $img['uuid'], $img['hash']); } // PIPA retention: purge worker snapshots older than the configured window. Runs rarely // (the gate) and off the critical path — the event is already stored. $days = $this->cfg->snapshotRetentionDays(); if ($days > 0 && ($this->purgeGate)()) { $this->snapshots->purgeOlderThan($now - $days * 86400); } return Response::json(['event_id' => $eventId], 201); } }