diff --git a/server/migrations/2026-07-20-move-table-prefix.sql b/server/migrations/2026-07-20-move-table-prefix.sql index aec952f..3d1b4ee 100644 --- a/server/migrations/2026-07-20-move-table-prefix.sql +++ b/server/migrations/2026-07-20-move-table-prefix.sql @@ -17,3 +17,7 @@ RENAME TABLE nonces TO move_nonces, rate_limits TO move_rate_limits, audit_log TO move_audit_log; + +-- Zone-editor background frame: SELECT ... FROM move_event_snapshots JOIN move_events +-- WHERE e.camera_id = ? would otherwise full-scan an unbounded table on an interactive page. +ALTER TABLE move_events ADD INDEX ix_events_camera (camera_id, event_id); diff --git a/server/public/assets/zone-draw.js b/server/public/assets/zone-draw.js index 0abb1ea..eed3bfe 100644 --- a/server/public/assets/zone-draw.js +++ b/server/public/assets/zone-draw.js @@ -4,9 +4,12 @@ * - Choose a level (warning/restricted/pinch), then Save -> POST /api/zones. * - "Load" fetches existing zones for the camera via GET /api/zones/{camera} * and redraws them; each has a Delete button -> POST /api/zones/{id}/delete. - * - If a `?snapshot={uuid}` query param is present, that image (served via - * the authenticated /snap/{uuid} proxy) is drawn as the canvas background; - * otherwise the canvas keeps its plain dark backdrop. + * - "현재 화면 불러오기" fetches the camera's newest snapshot uuid via + * GET /api/cameras/{camera}/latest-snapshot and draws it as the canvas + * background, so zones are drawn over what the camera actually sees. + * - A `?snapshot={uuid}` query param does the same for one specific image + * (the event-detail page links here that way); otherwise the canvas keeps + * its plain dark backdrop. * * XSS-safe: all dynamic text is set via textContent, never innerHTML. */ @@ -25,6 +28,7 @@ var cameraInput = document.getElementById('camera-id'); var levelSelect = document.getElementById('zone-level'); var loadBtn = document.getElementById('zone-load-btn'); + var frameBtn = document.getElementById('zone-frame-btn'); var clearBtn = document.getElementById('zone-clear-btn'); var saveBtn = document.getElementById('zone-save-btn'); var listEl = document.getElementById('zone-list'); @@ -130,6 +134,22 @@ img.src = B + '/snap/' + encodeURIComponent(uuid); } + // Load the camera's most recent frame as the drawing background. The uuid comes from the + // API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope. + function loadLatestFrame() { + var camera = cameraInput.value.trim(); + if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; } + setStatus('현재 화면을 불러오는 중...'); + fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' }) + .then(function (r) { + if (r.status === 404) throw new Error('이 카메라의 저장된 화면이 아직 없습니다. 이벤트가 한 번 발생해야 배경이 생깁니다.'); + if (!r.ok) throw new Error('불러오기 실패 (' + r.status + ')'); + return r.json(); + }) + .then(function (data) { loadBackground(data.uuid); setStatus('현재 화면을 배경으로 불러왔습니다.'); }) + .catch(function (e) { setStatus(e.message); }); + } + function loadZones() { var camera = cameraInput.value.trim(); if (!camera) { setStatus('카메라 ID를 입력하세요.'); return; } @@ -212,6 +232,7 @@ levelSelect.addEventListener('change', redraw); loadBtn.addEventListener('click', loadZones); + if (frameBtn) frameBtn.addEventListener('click', loadLatestFrame); clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('현재 폴리곤을 지웠습니다.'); }); saveBtn.addEventListener('click', saveZone); diff --git a/server/public/index.php b/server/public/index.php index cb92251..a6365a7 100644 --- a/server/public/index.php +++ b/server/public/index.php @@ -92,7 +92,7 @@ $csrfCtl = new CsrfController(); $list = new EventListController($events, $sess); $detail = new EventDetailController($events, $snaps, $audit, $sess); $proxy = new SnapshotProxy($snaps, $events, $sess); -$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess); +$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps); $render = function (string $view): Response { ob_start(); require APP_ROOT . '/src/View/' . $view . '.php'; @@ -110,6 +110,8 @@ $r->add('GET', '/api/events', fn(Request $q) => $list->json($q)); $r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p)); $r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p)); $r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p)); +// Background frame for the zone editor: the newest snapshot this camera produced. +$r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p)); $r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q)); $r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p)); $r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p)); diff --git a/server/schema.sql b/server/schema.sql index 4c03010..4c6059c 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -137,6 +137,7 @@ CREATE TABLE move_events ( KEY ix_events_severity_occurred_at (severity, occurred_at), KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b) KEY ix_events_device (device_id), + KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera CONSTRAINT chk_events_type CHECK (event_type IN ( 'fall','no_motion','immobility','ghost','zone_warning', 'zone_restricted','pinch','monitoring_degraded','device_fault' diff --git a/server/src/Dashboard/ZoneConfigController.php b/server/src/Dashboard/ZoneConfigController.php index 1795d3f..41bb472 100644 --- a/server/src/Dashboard/ZoneConfigController.php +++ b/server/src/Dashboard/ZoneConfigController.php @@ -6,7 +6,7 @@ use App\Http\{Request, Response}; use App\Auth\{SessionStore, Rbac}; use App\Security\{Csrf, SiteScope}; use App\Geometry\Polygon; -use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, ZoneOwnershipException}; +use App\Repository\{ZoneRepository, DeviceRepository, AuditRepository, SnapshotRepository, ZoneOwnershipException}; final class ZoneConfigController { @@ -14,7 +14,8 @@ final class ZoneConfigController public function __construct( private ZoneRepository $zones, private DeviceRepository $devices, - private AuditRepository $audit, private SessionStore $sess + private AuditRepository $audit, private SessionStore $sess, + private SnapshotRepository $snapshots ) {} private function guard(string $perm): ?Response @@ -45,6 +46,22 @@ final class ZoneConfigController return Response::json(['zones' => $zones], 200); } + /** GET /api/cameras/{camera}/latest-snapshot -> {"uuid": "..."} or 404. + * The zone editor loads /snap/{uuid} as its canvas background so zones are drawn over the + * camera's actual view. Returns only the uuid: the image itself still goes through + * SnapshotProxy, which re-checks the session and the site scope before serving bytes. */ + public function latestSnapshot(Request $req, array $params): Response + { + if ($g = $this->guard('view_events')) return $g; + $shot = $this->snapshots->latestForCamera((string)($params['camera'] ?? '')); + // 404 for out-of-scope as well as missing -- a scoped operator must not be able to + // tell "another site's camera" apart from "no such camera" (mirrors list()). + if ($shot === null || !$this->inScope((string)($shot['device_id'] ?? ''))) { + return Response::json(['error' => 'not_found'], 404); + } + return Response::json(['uuid' => $shot['file_uuid']], 200); + } + public function save(Request $req): Response { if ($g = $this->guard('edit_zones')) return $g; diff --git a/server/src/Repository/Pdo/PdoSnapshotRepository.php b/server/src/Repository/Pdo/PdoSnapshotRepository.php index 0ae13c8..75e9627 100644 --- a/server/src/Repository/Pdo/PdoSnapshotRepository.php +++ b/server/src/Repository/Pdo/PdoSnapshotRepository.php @@ -28,6 +28,27 @@ final class PdoSnapshotRepository implements SnapshotRepository 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 -- diff --git a/server/src/Repository/SnapshotRepository.php b/server/src/Repository/SnapshotRepository.php index 69c6cf4..c6c1d7c 100644 --- a/server/src/Repository/SnapshotRepository.php +++ b/server/src/Repository/SnapshotRepository.php @@ -11,6 +11,12 @@ interface SnapshotRepository * snapshot's site (via the owning event) for access control. */ public function eventIdByUuid(string $uuid): ?int; + /** Most recent snapshot for a camera, as ['file_uuid' => ..., 'device_id' => ...], or null. + * Backs the zone editor's "load the latest frame" button: an operator draws hazard zones + * over what the camera actually sees instead of guessing coordinates on a blank canvas. + * device_id comes back so the caller can site-scope the result. */ + public function latestForCamera(string $cameraId): ?array; + /** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention: * worker images must not be kept forever. Returns the number purged. */ public function purgeOlderThan(int $cutoff): int; diff --git a/server/src/View/zones.php b/server/src/View/zones.php index 3d77ad8..9b949e2 100644 --- a/server/src/View/zones.php +++ b/server/src/View/zones.php @@ -9,6 +9,7 @@ render_header('구역 설정'); +
diff --git a/server/tests/DashboardTest.php b/server/tests/DashboardTest.php index 8fcfe6e..937b131 100644 --- a/server/tests/DashboardTest.php +++ b/server/tests/DashboardTest.php @@ -130,6 +130,7 @@ final class DashboardTest extends TestCase public function forEvent(int $e): array { return []; } public function pathByUuid(string $u): ?string { return $u === $this->uuid ? $this->file : null; } public function eventIdByUuid(string $u): ?int { return $u === $this->uuid ? $this->eid : null; } + public function latestForCamera(string $cameraId): ?array { return null; } public function purgeOlderThan(int $c): int { return 0; } }; $cross = new SnapshotProxy($snaps, $repo, $this->authedSite('factory-a')); diff --git a/server/tests/IngestControllerTest.php b/server/tests/IngestControllerTest.php index 3b806ff..aac6582 100644 --- a/server/tests/IngestControllerTest.php +++ b/server/tests/IngestControllerTest.php @@ -111,6 +111,7 @@ final class IngestControllerTest extends TestCase public function forEvent(int $e): array { return []; } public function pathByUuid(string $u): ?string { return null; } public function eventIdByUuid(string $u): ?int { return null; } + public function latestForCamera(string $cameraId): ?array { return null; } public function purgeOlderThan(int $c): int { return 0; } }; $events = new InMemoryEventRepository(); diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php index 40903a7..267715d 100644 --- a/server/tests/Support/InMemoryRepositories.php +++ b/server/tests/Support/InMemoryRepositories.php @@ -65,6 +65,17 @@ final class InMemorySnapshotRepository implements SnapshotRepository { foreach ($this->rows as $e => $list) foreach ($list as $r) if ($r['file_uuid'] === $u) return (int)$e; return null; } + /** @var array file_uuid => camera_id, recorded by the tests that need it */ + public array $cameras = []; + /** @var array file_uuid => device_id */ + public array $devices = []; + public function latestForCamera(string $cameraId): ?array { + // Insert order, newest first -- mirrors the Pdo impl's ORDER BY snapshot_id DESC. + foreach (array_reverse($this->cameras, true) as $uuid => $cam) { + if ($cam === $cameraId) return ['file_uuid' => $uuid, 'device_id' => $this->devices[$uuid] ?? '']; + } + return null; + } public function purgeOlderThan(int $cutoff): int { $n = 0; foreach ($this->created as $uuid => $ts) { diff --git a/server/tests/ZoneConfigTest.php b/server/tests/ZoneConfigTest.php index 31510d1..015652d 100644 --- a/server/tests/ZoneConfigTest.php +++ b/server/tests/ZoneConfigTest.php @@ -5,7 +5,7 @@ use App\Http\Request; use App\Auth\ArraySessionStore; use App\Geometry\Polygon; use App\Dashboard\ZoneConfigController; -use App\Tests\Support\{InMemoryZoneRepository, InMemoryDeviceRepository, InMemoryAuditRepository}; +use App\Tests\Support\{InMemoryZoneRepository, InMemoryDeviceRepository, InMemoryAuditRepository, InMemorySnapshotRepository}; use PHPUnit\Framework\TestCase; final class ZoneConfigTest extends TestCase @@ -28,13 +28,13 @@ final class ZoneConfigTest extends TestCase return new Request('POST', '/api/zones', $headers, json_encode($body), [], []); } public function testSaveRequiresPermission(): void { - $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('viewer')); + $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('viewer'), new InMemorySnapshotRepository()); $res = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]])); $this->assertSame(403, $res->status()); } public function testSaveValidZone(): void { $repo = new InMemoryZoneRepository(); - $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator')); + $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); $res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]])); $this->assertSame(201, $res->status()); $this->assertCount(1, $repo->forCamera('cam-1')); @@ -50,7 +50,7 @@ final class ZoneConfigTest extends TestCase public function save(array $z): string { throw new \App\Repository\ZoneOwnershipException('not owned'); } public function delete(string $id): bool { return true; } }; - $c = new ZoneConfigController($throwing, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator')); + $c = new ZoneConfigController($throwing, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); $res = $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-2', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]])); $this->assertSame(400, $res->status()); @@ -65,19 +65,19 @@ final class ZoneConfigTest extends TestCase public function save(array $z): string { throw new \PDOException('server has gone away'); } public function delete(string $id): bool { return true; } }; - $c = new ZoneConfigController($dbFault, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator')); + $c = new ZoneConfigController($dbFault, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); $this->expectException(\PDOException::class); $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]])); } public function testSaveRejectsBadLevelOrPolygon(): void { - $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('operator')); + $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status()); $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'warning', 'polygon' => [[0,0]]]))->status()); } public function testSaveRejectsMissingOrWrongCsrf(): void { $repo = new InMemoryZoneRepository(); - $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator')); + $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); $body = ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]; // missing header $this->assertSame(403, $c->save($this->saveReq($body, []))->status()); @@ -89,7 +89,7 @@ final class ZoneConfigTest extends TestCase public function testDeleteRejectsMissingOrWrongCsrf(): void { $repo = new InMemoryZoneRepository(); $repo->save(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']); - $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator')); + $c = new ZoneConfigController($repo, $this->devices(), new InMemoryAuditRepository(), $this->authed('operator'), new InMemorySnapshotRepository()); // missing header $missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []); $this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status()); @@ -115,7 +115,7 @@ final class ZoneConfigTest extends TestCase } private function scopedController(InMemoryZoneRepository $repo, ?string $site): ZoneConfigController { return new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']), - new InMemoryAuditRepository(), $this->authed('operator', $site)); + new InMemoryAuditRepository(), $this->authed('operator', $site), new InMemorySnapshotRepository()); } private function zonesOf($res): array { return json_decode($res->body(), true)['zones']; @@ -152,13 +152,56 @@ final class ZoneConfigTest extends TestCase public function testGlobalOperatorSeesEverySite(): void { $repo = $this->seededRepo(); $c = new ZoneConfigController($repo, $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']), - new InMemoryAuditRepository(), $this->authed('operator', null)); // no site_scope = global + new InMemoryAuditRepository(), $this->authed('operator', null), new InMemorySnapshotRepository()); // no site_scope = global $res = $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a']); $this->assertCount(1, $this->zonesOf($res)); } public function testListRequiresAuthenticatedRole(): void { $s = new ArraySessionStore(); // no user_id - $c = new ZoneConfigController($this->seededRepo(), $this->devices(), new InMemoryAuditRepository(), $s); + $c = new ZoneConfigController($this->seededRepo(), $this->devices(), new InMemoryAuditRepository(), $s, new InMemorySnapshotRepository()); $this->assertSame(401, $c->list(new Request('GET', '/api/zones/cam-a', [], '', [], []), ['camera' => 'cam-a'])->status()); } + + // --- latest-snapshot background for the zone editor ------------------------------- + private function snapsWith(array $map, array $devices = []): InMemorySnapshotRepository { + $r = new InMemorySnapshotRepository(); + $r->cameras = $map; $r->devices = $devices; + return $r; + } + private function snapReq(): Request { return new Request('GET', '/api/cameras/cam-a/latest-snapshot', [], '', [], []); } + + public function testLatestSnapshotReturnsTheNewestUuidForTheCamera(): void { + $uuidOld = str_repeat('a', 32); $uuidNew = str_repeat('b', 32); + $snaps = $this->snapsWith([$uuidOld => 'cam-a', $uuidNew => 'cam-a'], [$uuidOld => 'pi-1', $uuidNew => 'pi-1']); + $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), + $this->authed('operator'), $snaps); + $res = $c->latestSnapshot($this->snapReq(), ['camera' => 'cam-a']); + $this->assertSame(200, $res->status()); + $this->assertSame($uuidNew, json_decode($res->body(), true)['uuid']); + } + + public function testLatestSnapshotIs404WhenTheCameraHasNoSnapshotYet(): void { + $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), + $this->authed('operator'), new InMemorySnapshotRepository()); + $this->assertSame(404, $c->latestSnapshot($this->snapReq(), ['camera' => 'cam-a'])->status()); + } + + public function testLatestSnapshotRequiresAuthentication(): void { + $c = new ZoneConfigController(new InMemoryZoneRepository(), $this->devices(), new InMemoryAuditRepository(), + new ArraySessionStore(), new InMemorySnapshotRepository()); + $this->assertSame(401, $c->latestSnapshot($this->snapReq(), ['camera' => 'cam-a'])->status()); + } + + public function testScopedOperatorCannotSeeAnotherSitesCameraFrame(): void { + // The frame is a worker image: leaking it across sites would defeat the whole scope guard. + $uuid = str_repeat('c', 32); + $snaps = $this->snapsWith([$uuid => 'cam-b'], [$uuid => 'pi-b']); + $devices = $this->devices(['pi-a' => 'factory-a', 'pi-b' => 'factory-b']); + $mine = new ZoneConfigController(new InMemoryZoneRepository(), $devices, new InMemoryAuditRepository(), + $this->authed('operator', 'factory-a'), $snaps); + $this->assertSame(404, $mine->latestSnapshot($this->snapReq(), ['camera' => 'cam-b'])->status()); + $theirs = new ZoneConfigController(new InMemoryZoneRepository(), $devices, new InMemoryAuditRepository(), + $this->authed('operator', 'factory-b'), $snaps); + $this->assertSame(200, $theirs->latestSnapshot($this->snapReq(), ['camera' => 'cam-b'])->status()); + } }