- P3-2 [H] snapshot retention (7 days): snapshot_retention_days config;
SnapshotRepository::purgeOlderThan (interface + Pdo + double, files-then-rows,
bounded batch); opportunistic purge in the ingest path (Cafe24 has no cron).
- P4-1 [H] index.php sets display_errors=0 + a global exception handler → generic
{"error":"internal"} + error_log (no DSN/db_user leak); ships www/move_detect/.user.ini.
- P4-2 [H] ConfigController + HeartbeatController take an optional rate limiter +
clientIp; pre-auth per-IP anti-flood → 429 (were unthrottled).
- P4-3 [M] PdoNonceRepository::remember + PdoRateLimiterRepository::hit opportunistically
reap expired rows (Cafe24 has no cron; tables grew unbounded).
- P4-4 [M] Router::dispatch rawurldecodes captured params (Korean/space camera_id could
save zones but never load them).
Tests: python green; phpunit 84 green. See docs/delivery-audit.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
85 lines
3.9 KiB
PHP
85 lines
3.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Ingest;
|
|
|
|
use App\Config;
|
|
use App\Http\{Request, Response};
|
|
use App\Security\Hmac;
|
|
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
|
|
EventRepository, SnapshotRepository};
|
|
|
|
final class IngestController
|
|
{
|
|
public function __construct(
|
|
private Config $cfg,
|
|
private DeviceRepository $devices,
|
|
private NonceRepository $nonces,
|
|
private RateLimiterRepository $rateLimiter,
|
|
private EventRepository $events,
|
|
private SnapshotRepository $snapshots,
|
|
// Opportunistic retention runs here because Cafe24 shared hosting has no cron, and the
|
|
// edge POSTs to this route 24/7 — so this is the one path that fires automatically. The
|
|
// gate keeps it rare (a purge query per ~1/200 ingests); tests inject `fn() => 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);
|
|
}
|
|
}
|