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); } }