- 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.
145 lines
6.3 KiB
PHP
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);
|
|
}
|
|
}
|