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.
120 lines
4.9 KiB
PHP
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);
|
|
}
|
|
}
|