move_detect/cloud/src/Repository/Pdo/PdoNonceRepository.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

40 lines
1.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\NonceRepository;
use PDO;
/**
* `nonces.nonce` is the PRIMARY KEY (see schema.sql). IngestController already
* calls seen() before remember(), so under normal operation remember() never
* collides. Under a replay race (two requests with the same nonce arriving
* concurrently), the PK uniqueness constraint is the real guard: the losing
* remember() throws a PDOException on the duplicate key instead of silently
* overwriting -- closing the seen()/remember() TOCTOU flagged in the Task 7
* review. We deliberately do not catch/swallow that exception here.
*/
final class PdoNonceRepository implements NonceRepository
{
public function __construct(private PDO $pdo) {}
public function seen(string $nonce): bool
{
$st = $this->pdo->prepare('SELECT 1 FROM nonces WHERE nonce = ? LIMIT 1');
$st->execute([$nonce]);
return (bool)$st->fetchColumn();
}
public function remember(string $nonce, int $expiresAt): void
{
$st = $this->pdo->prepare('INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)');
$st->execute([$nonce, $expiresAt]);
// Cron-less reap: Cafe24 has no scheduler, so expired nonces accumulate forever until
// the table bloats and inserts slow/fail. Opportunistically prune a bounded batch of
// already-expired rows (safe: seen() only cares about unexpired ones).
if (mt_rand(1, 100) === 1) {
$del = $this->pdo->prepare('DELETE FROM nonces WHERE expires_at < ? LIMIT 5000');
$del->execute([time()]);
}
}
}