move_detect/cloud/src/Ingest/IngestController.php
유창욱 1187df5628 feat(delivery-audit): PIPA retention + cloud hardening (P3-2, P4-1..4)
- 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
2026-07-17 09:28:16 +09:00

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