move_detect/server/src/Repository/Pdo/PdoSnapshotRepository.php
유창욱 bf5255e9eb feat(dashboard): device-status panel + event-list rework, hardened
- Device-status panel (DeviceStatusController): site-scoped liveness over stored
  heartbeat telemetry. Fix config_drift false-positive by storing NULL until the
  device actually reports a version (was firing "설정 미적용" on every device).
- Zone save rejects a blank/unknown device_id (DeviceRepository::exists) so a zone
  is never persisted against a device that will never fetch it.
- Snapshot proxy sends immutable private cache headers, ending the 5s-poll
  re-download of every thumbnail (a Cafe24 traffic-cap hazard).
- Event list: paged-in acknowledgements stick; honesty affordances for the paging
  gap, a stale device poll, and a filtered camera aging out; logout wired for
  every page via the shared layout.
2026-07-25 19:20:42 +09:00

145 lines
6.3 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 thumbUuidsFor(array $eventIds): array
{
// Sanitize to positive ints and build the IN list from their COUNT — never interpolate
// caller values into SQL. Empty -> no query.
$ids = array_values(array_filter(array_map('intval', $eventIds), static fn (int $i): bool => $i > 0));
if ($ids === []) return [];
$in = implode(',', array_fill(0, count($ids), '?'));
// Newest snapshot per event: the grouped subquery picks MAX(snapshot_id) per event (the
// server's own insert sequence, not the device clock), then joins back to read that row's
// uuid. One round-trip, rides ix_event_snapshots_event — no N+1 across the page.
$sql = "SELECT s.event_id, s.file_uuid
FROM move_event_snapshots s
JOIN (SELECT event_id, MAX(snapshot_id) AS mx
FROM move_event_snapshots
WHERE event_id IN ($in)
GROUP BY event_id) m
ON m.event_id = s.event_id AND m.mx = s.snapshot_id";
$st = $this->pdo->prepare($sql);
foreach ($ids as $i => $v) $st->bindValue($i + 1, $v, PDO::PARAM_INT);
$st->execute();
$out = [];
foreach ($st->fetchAll() as $r) $out[(int)$r['event_id']] = (string)$r['file_uuid'];
return $out;
}
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);
}
}