move_detect/server/src/Repository/Pdo/PdoSnapshotRepository.php
유창욱 aa4378e906 feat(dashboard): load a camera's latest frame as the zone-editor background
Drawing hazard zones on a blank canvas meant guessing pixel coordinates.
The editor can now pull the camera's most recent snapshot and draw over
what the camera actually sees.

- SnapshotRepository::latestForCamera() (+ Pdo/in-memory impls), ordered by
  snapshot_id, not the device-reported occurred_at (a skewed Pi RTC would
  otherwise pin 'latest' to a stale frame)
- GET /api/cameras/{camera}/latest-snapshot -> {uuid}; returns only the id,
  the bytes still go through SnapshotProxy's session + site-scope check.
  Out-of-scope and missing both 404, so a scoped operator cannot probe for
  another site's cameras
- ix_events_camera (camera_id, event_id) so the lookup is not a full scan
- '현재 화면 불러오기' button in the zone editor

Tests: PHP 113 pass (4 skipped, 4 new), pytest 174 pass.
2026-07-20 23:41:58 +09:00

120 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\SnapshotRepository;
use PDO;
final class PdoSnapshotRepository implements SnapshotRepository
{
public function __construct(private PDO $pdo, private string $snapshotDir) {}
public function add(int $eventId, string $fileUuid, string $contentHash): void
{
$st = $this->pdo->prepare(
'INSERT INTO move_event_snapshots (event_id, file_uuid, content_hash, created_at)
VALUES (?, ?, ?, ?)'
);
$st->execute([$eventId, $fileUuid, $contentHash, time()]);
}
public function forEvent(int $eventId): array
{
$st = $this->pdo->prepare(
'SELECT file_uuid, content_hash FROM move_event_snapshots
WHERE event_id = ? ORDER BY snapshot_id ASC'
);
$st->execute([$eventId]);
return $st->fetchAll();
}
public function latestForCamera(string $cameraId): ?array
{
// Ordered by snapshot_id (insert order), NOT events.occurred_at: occurred_at is the
// DEVICE's clock, so a Pi whose RTC is behind would keep serving a stale frame as
// "latest" forever. snapshot_id is the server's own monotonic sequence.
// Rides ix_events_camera (camera_id, event_id) -- without it this is a full scan of a
// table that grows unbounded, on a page an operator opens interactively.
$st = $this->pdo->prepare(
'SELECT s.file_uuid, e.device_id
FROM move_event_snapshots s
JOIN move_events e ON e.event_id = s.event_id
WHERE e.camera_id = ?
ORDER BY s.snapshot_id DESC
LIMIT 1'
);
$st->execute([$cameraId]);
$row = $st->fetch();
return $row === false ? null : ['file_uuid' => (string)$row['file_uuid'],
'device_id' => (string)$row['device_id']];
}
public function pathByUuid(string $uuid): ?string
{
// Validate BEFORE building any filesystem path or touching the DB --
// SnapshotProxy trusts whatever this method returns and does
// file_get_contents() on it, so a non-hex/wrong-length uuid must never
// reach path construction (closes the path-traversal risk flagged in
// the Task 12 review).
if (strlen($uuid) !== 32 || !ctype_xdigit($uuid)) {
return null;
}
$st = $this->pdo->prepare('SELECT 1 FROM move_event_snapshots WHERE file_uuid = ? LIMIT 1');
$st->execute([$uuid]);
if ($st->fetchColumn() === false) {
return null;
}
return rtrim($this->snapshotDir, '/\\') . '/' . $uuid . '.jpg';
}
public function eventIdByUuid(string $uuid): ?int
{
if (strlen($uuid) !== 32 || !ctype_xdigit($uuid)) {
return null;
}
$st = $this->pdo->prepare('SELECT event_id FROM move_event_snapshots WHERE file_uuid = ? LIMIT 1');
$st->execute([$uuid]);
$id = $st->fetchColumn();
return $id === false ? null : (int)$id;
}
public function purgeOlderThan(int $cutoff, int $limit = 500): int
{
// Bounded batch: a single request must not stall unlinking thousands of files on a
// shared host. Repeated ingests drain the backlog. Files first, then rows — an orphan
// row (file gone, row left) is harmless; an orphan file (row gone, file left) is the
// PIPA leak we are closing, so never delete the row before its file.
$sel = $this->pdo->prepare(
'SELECT snapshot_id, file_uuid FROM move_event_snapshots WHERE created_at < ? LIMIT ?');
$sel->bindValue(1, $cutoff, PDO::PARAM_INT);
$sel->bindValue(2, $limit, PDO::PARAM_INT);
$sel->execute();
$rows = $sel->fetchAll();
if (!$rows) {
return 0;
}
$ids = [];
foreach ($rows as $r) {
$path = rtrim($this->snapshotDir, '/\\') . '/' . $r['file_uuid'] . '.jpg';
// Only delete the DB row once its file is actually gone. If unlink() fails (perms, a
// lock, or a hosting-support restore that reassigned the owner), KEEP the row so the
// next purge retries it. Deleting the row while the JPEG survives makes the file
// permanently untrackable — the row-driven purge can never find it again — leaving a
// worker image on disk past the PIPA retention window, the exact leak this closes.
if (is_file($path) && @unlink($path) === false) {
error_log("snapshot purge: unlink failed for $path; keeping row for next purge");
continue;
}
$ids[] = (int)$r['snapshot_id'];
}
if (!$ids) {
return 0;
}
$in = implode(',', array_fill(0, count($ids), '?'));
$del = $this->pdo->prepare("DELETE FROM move_event_snapshots WHERE snapshot_id IN ($in)");
$del->execute($ids);
return count($ids);
}
}