diff --git a/.forgejo/workflows/verify.yml b/.forgejo/workflows/verify.yml new file mode 100644 index 0000000..d1d7913 --- /dev/null +++ b/.forgejo/workflows/verify.yml @@ -0,0 +1,44 @@ +# The repo had no CI at all, which meant PdoIntegrationTest — the only test that runs the +# delivered SQL rather than an in-memory double — had almost certainly never executed anywhere: +# it skips itself unless pdo_sqlite is loaded, and no machine was loading it. +# +# Same two commands as the local `verify` in CLAUDE.md. Keep them identical; a CI that runs +# something else is a CI that proves something else. +name: verify + +on: + push: + pull_request: + +jobs: + edge: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' # Pi OS Bookworm ships CPython 3.11; match the target + - run: pip install -e ".[dev]" # deps + pytest come from pyproject.toml + - run: python -m pytest tests -q + + server: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' # Cafe24's floor (see server/docs/server-deploy.md) + # pdo_sqlite is what un-skips the DB integration test; gd/sodium mirror the host's + # image + Argon2id requirements so a missing-extension failure shows up here, not + # on the customer's shared hosting. + extensions: pdo_sqlite, gd, sodium + - run: composer install --no-interaction --prefer-dist + working-directory: server + - run: php vendor/bin/phpunit + working-directory: server + - name: fail if any test skipped + working-directory: server + # A skip is how the DB coverage silently disappeared in the first place. In CI every + # test must actually run, so a re-introduced skip gate fails the build instead of + # quietly shrinking coverage back down. + run: php vendor/bin/phpunit --do-not-cache-result | tee out.txt; ! grep -q "Skipped:" out.txt diff --git a/CLAUDE.md b/CLAUDE.md index 368acc6..7560625 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,13 @@ Pi5+Hailo-8 엣지(Python `edge/`) → HMAC POST → Cafe24 PHP+MySQL 대시보 ## 검증 (verify) ``` -python -m pytest tests -q # edge (203+) -cd server && php vendor/bin/phpunit # server (119+, PDO 4 skip) +python -m pytest tests -q # edge (247+) +cd server && php -d extension=pdo_sqlite vendor/bin/phpunit # server (162+, skip 0) ``` +`-d extension=pdo_sqlite` 없이 돌리면 PDO 통합 테스트(납품되는 실제 SQL)가 통째로 스킵된다. +CI(`.forgejo/workflows/verify.yml`)는 같은 명령을 쓰고 스킵이 하나라도 있으면 실패시킨다. + ## 제약 - ultralytics(AGPL) 금지 — Hailo HEF/MoveNet만. 라이브뷰·영상 전송 금지(스냅샷만, 얼굴 블러). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..ac39a10 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.9.0 diff --git a/deploy/README.md b/deploy/README.md index 5f0509f..8ed22bf 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -6,11 +6,13 @@ performs **zero network fetches** at install or run time. The only runtime network use is the server POST / config-poll / heartbeat, and even that tolerates being offline (events buffer in the local sqlite queue and flush on reconnect). -> Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the -> daemon wires today (`edge/daemon.py:main`) and the one verified end-to-end. -> Hailo-8 acceleration (`edge/adapters/hailo_pose.py`) and the CSI camera -> (`edge/adapters/picamera_source.py`) are separate device-validated paths — see -> `docs/hardware-bringup.md`. +> Scope: the delivered bundle is **Hailo-8 + `yolov8s_pose.hef`**, which +> `build_offline_bundle.sh` fetches (sha256-pinned) and stages into `models/`. +> The CPU MoveNet fallback is **not** bundled: its upstream ONNX URL 404s, so +> `install_offline.sh` configures the unit for whatever backend is actually +> present and refuses to install if none is. Camera side: USB (UVC) and CSI are +> both wired; CSI needs `hailo-all`/`picamera2` from the Pi OS image. +> Device validation for both is `docs/hardware-bringup.md` (still OFF-HW). ## Files @@ -105,17 +107,18 @@ sudo systemctl enable --now move-detect-edge journalctl -u move-detect-edge -f ``` -`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the server (see -`server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled -model (`/opt/move-detect/models/movenet_lightning.onnx`). +`DEVICE_ID`/`DEVICE_SECRET` must match a `move_devices` row in the server (see +`server/docs/server-deploy.md` §6). `HAILO_HEF` points at the bundled model +(`/opt/move-detect/models/yolov8s_pose.hef`); the installer sets it for you and +only falls back to `MOVENET_ONNX` if a MoveNet bundle was built. ## 4. Verify the air gap (do this before trusting a build) - [ ] On a host with **networking disabled**, `install_offline.sh` completes — the `--no-index` installs prove nothing is fetched. If pip tries to reach PyPI, a wheel is missing from the bundle: rebuild (step 1) natively. -- [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 + - numpy + requests all import from the venv). +- [ ] Step [4/6] of the installer prints `edge import OK` (cv2 + numpy + requests + import from the venv; onnxruntime only if a MoveNet bundle was built). - [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows frames processed and (if the server is reachable) heartbeats. - [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore @@ -166,6 +169,10 @@ Each `camera_id` must match the `camera_id` on that camera's zones in the server ## Not bundled (by design) - **MySQL / the server app** — a separate host; see `server/docs/server-deploy.md`. -- **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled - HEF, and the CSI camera stack are Pi system packages installed out-of-band (above); - the default CPU MoveNet + USB path needs none of them. +- **HailoRT + picamera2** — Hailo's runtime/driver and the CSI camera stack are Pi + system packages, installed out-of-band (above). Note the HEF itself **is** bundled + (`models/yolov8s_pose.hef`); only the driver is not. +- **`python3-venv`** — `install_offline.sh` runs `python3 -m venv`, which Pi OS Lite + does not always include. Install it with the Hailo packages, before going air-gapped. +- **MoveNet ONNX (CPU fallback)** — its upstream URL 404s, so the bundle is Hailo-only. + If you need the CPU path, supply the ONNX to the build machine and rebuild. diff --git a/deploy/bundle/build_offline_bundle.sh b/deploy/bundle/build_offline_bundle.sh index 3b52b64..61ee22c 100755 --- a/deploy/bundle/build_offline_bundle.sh +++ b/deploy/bundle/build_offline_bundle.sh @@ -155,8 +155,19 @@ sort -u -o "$STAGE/requirements.lock" "$STAGE/requirements.lock" # air gap. Hailo needs the HEF (present) + system hailo_platform on the Pi; MoveNet needs the ONNX. HAS_ONNX=no; [ -f "$STAGE/models/movenet_lightning.onnx" ] && HAS_ONNX=yes SIGNED=no; [ -n "${SIGN_KEY:-}" ] && SIGNED=yes +# Provenance. Without this, a bundle is unidentifiable once it leaves the build machine: the +# SHA256SUMS inside a STALE bundle is perfectly self-consistent, so integrity checks pass while +# the contents are months behind the source. version+commit is how a site reports what it runs. +BUNDLE_VERSION="$(cat "$REPO_ROOT/VERSION" 2>/dev/null || echo unknown)" +GIT_REV="$(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)" +GIT_DIRTY="" +(cd "$REPO_ROOT" && git diff --quiet HEAD -- edge deploy 2>/dev/null) || GIT_DIRTY=" (uncommitted changes)" +BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" { echo "bundle: move-detect-edge" + echo "version: $BUNDLE_VERSION" + echo "git_commit: ${GIT_REV}${GIT_DIRTY}" + echo "built_at: $BUILD_DATE" echo "built_mode: $MODE" echo "python: $PYVER ($PYABI)" echo "target_arch: $TARGET_ARCH" diff --git a/deploy/systemd/move-detect-edge.env.example b/deploy/systemd/move-detect-edge.env.example index 08e3cae..6914209 100644 --- a/deploy/systemd/move-detect-edge.env.example +++ b/deploy/systemd/move-detect-edge.env.example @@ -19,12 +19,15 @@ CAFE24_URL=https://your-domain.example/move_detect CAMERA_ID=cam-01 # --- pose backend / capture --- -# POSE_BACKEND: "movenet" (CPU, needs MOVENET_ONNX) or "hailo" (needs HAILO_HEF). -POSE_BACKEND=movenet -# MOVENET_ONNX: required when POSE_BACKEND=movenet. -MOVENET_ONNX=/opt/move-detect/models/movenet_lightning.onnx -# HAILO_HEF: required when POSE_BACKEND=hailo (uncomment and set). -#HAILO_HEF=/opt/move-detect/models/yolov8s_pose.hef +# POSE_BACKEND: "hailo" (Pi 5 + AI HAT+, needs HAILO_HEF) or "movenet" (CPU, needs MOVENET_ONNX). +# The delivered bundle carries the HEF only — the MoveNet ONNX has no reachable upstream, so a +# movenet unit would point at a file that is not there and fail to start. install_offline.sh +# rewrites these to match whatever the bundle actually contains; edit them only if you know the +# file exists on this device. +POSE_BACKEND=hailo +HAILO_HEF=/opt/move-detect/models/yolov8s_pose.hef +# MOVENET_ONNX: required only when POSE_BACKEND=movenet (CPU fallback; not bundled by default). +#MOVENET_ONNX=/opt/move-detect/models/movenet_lightning.onnx # CAMERA_BACKEND: "uvc" (USB webcam, uses UVC_DEVICE) or "csi" (Pi CSI camera, uses CSI_DEVICE). CAMERA_BACKEND=uvc # UVC_DEVICE: OpenCV VideoCapture index for the USB (UVC) webcam. diff --git a/deploy/systemd/move-detect-edge.service b/deploy/systemd/move-detect-edge.service index e16fd9a..5a2ff31 100644 --- a/deploy/systemd/move-detect-edge.service +++ b/deploy/systemd/move-detect-edge.service @@ -43,6 +43,21 @@ ExecStart=/opt/move-detect/venv/bin/python -m edge Restart=always RestartSec=5 +# Catch the failure Restart=always CANNOT: alive but wedged. run_loop pings WATCHDOG=1 at the +# top of every tick, so a blocked camera read / accelerator call / sqlite lock stops the pings +# and systemd restarts the unit instead of leaving an unattended site silently unmonitored. +# 120s, not a tight value: one tick may legitimately include an offline-queue flush slice whose +# POSTs each carry a 5s timeout. Too tight would restart a HEALTHY daemon mid-recovery. +WatchdogSec=120 + +# Memory ceiling. The evidence ring buffers ~90 frames per camera, so a mis-set capture size or +# a leak grows RSS until the kernel OOM killer fires — and the kernel may kill something else +# entirely, leaving the Pi in an unpredictable state. Let systemd kill THIS unit instead, where +# Restart=always brings it straight back. MemoryHigh throttles first to ride out a brief spike. +MemoryHigh=1200M +MemoryMax=1500M +OOMPolicy=restart + # Writable state (offline queue sqlite lives here; keep QUEUE_DB pointed at it). # systemd creates/owns /var/lib/move-detect for the service user. StateDirectory=move-detect diff --git a/docs/INDEX.md b/docs/INDEX.md index 6ded068..eca2a5b 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,19 @@ # INDEX — 작업 기록 (완료 결과만) +- **2026-08-08** 감사 잔여 확정 항목 일괄 처리 (VERSION 0.9.0 신설). **보안**: CSP nonce + 도입(인라인 2블록, unsafe-inline 없음)+보안헤더를 PHP로 이관(개발/납품 동일), /logout CSRF, + 계정 잠금 423→401(사용자명 열거 제거). **엣지**: 큐 flush 배치 상한(정전 복구 시 감지 정지 + 해소), 타이머 env 전수 NaN/음수 검증, T2 프레임 스킵(`INFER_EVERY`, 스킵 틱은 링버퍼 미투입 + =미블러 얼굴 유출 차단), systemd 워치독(sd_notify)+메모리 상한, UVC 해상도·버퍼 고정. + **테스트**: PDO 통합 테스트 스킵 해소(`-d extension=pdo_sqlite`, 리포트·히트맵·익스포트 + 집계 SQL 최초 실행), 얼굴 블러 테스트를 실제 증거로 교체(무동작 퇴화 감지), index.php 라우트 + 테이블 대조(프런트 호출 15개 교차검증), dist↔소스 패리티 테스트, CI 신설 + (`.forgejo/workflows/verify.yml`, 스킵 발생 시 실패). **프런트**: 이벤트 목록 시그니처 기반 + 재렌더 스킵+포커스 복원(5초마다 포커스 소실·전체 재낭독 해소), 폴링 중첩 가드, 콜드스타트 + 실패 표시. **문서**: deploy/README·env.example을 Hailo 단독 번들 실제와 일치, bringup의 + 미존재 설정 키를 실제 env 노브로 교체. 납품물에 version/git_commit/built_at 스탬프. + pytest 251 / PHPUnit 162 (스킵 0). + - **2026-08-08** 프로젝트 8개 차원 감사(종합 82/100) 후 상위 3건 수정. ①개선 조치 site 스코프 누락(스코프 운영자가 타 현장 조치 기록 열람·기록 가능) → `move_actions.site` 추가 (`2026-08-08-actions-site.sql`, 이벤트에서 카메라 소유 현장 해석, 오타 카메라 400·타 현장 diff --git a/docs/hardware-bringup.md b/docs/hardware-bringup.md index ce0d3a5..0e2c7c2 100644 --- a/docs/hardware-bringup.md +++ b/docs/hardware-bringup.md @@ -65,8 +65,15 @@ Measure frame processing under three operational modes: | **T2: Detection** | Inference every 2–3 frames | Trade-off: skip frames to reduce Hailo load. | ≥ 8 fps | For additional context cameras; acceptable for non-critical zones. | | **T3: Motion** | Hailo only on motion keyframes | Inference triggered by motion (v4l2 MJPEG keyframe or frame diff). | ≥ 5 fps avg | For background monitoring; many cameras, low overhead. | -1. Configure edge detection core to support tier selection (via thresholds file or env var). -2. Run each tier with realistic camera setup (e.g., T1 on main fall cam, T2/T3 on perimeter). +1. Tier selection is `INFER_EVERY` in `/etc/move-detect/edge.env` (per device): + `1` = T1 (every tick, the default), `2`–`3` = T2. On a skipped tick the frame is still read + and discarded — the camera buffer stays current — but no pose runs and nothing enters the + evidence ring, so no unblurred frame can reach a snapshot. + **T3 (motion-gated inference) is deliberately NOT implemented.** A motion gate that misjudges + would stop inferring on a *motionless fallen worker* — the exact case this product exists to + catch — and its threshold cannot be tuned without real footage. Decide it during bring-up + with measured video, not in advance. +2. Run each tier with realistic camera setup (e.g., T1 on main fall cam, T2 on perimeter). 3. Log FPS, E2E latency (frame capture → alert), CPU, Hailo temp for 30 min per tier. 4. Confirm T1 ≥ 12 fps for the target scene (well-lit, medium activity). @@ -97,14 +104,23 @@ Validate sustained operation and resource cleanup: After benchmark completion, update configuration: -1. **`edge/config.py` thresholds:** - - Adjust `motion.cooldown_ms`, `motion.min_duration_ms` based on measured T2/T3 FPS. - - Tune `fall.confidence_threshold` if fall recall vs. precision trade-off differs on real video. +Every knob below lives in `/etc/move-detect/edge.env` (read by `edge/config.py::load_config`), +**not** in a code file — no redeploy is needed to retune a site. All are validated at startup: +a non-finite or non-positive value refuses to boot rather than silently disabling a detector. +The full list is in `docs/pilot-runbook.md` §3. -2. **`edge/config.py` camera roles:** - - Assign `role="fall_primary"` to high-fps T1 cam (≥ 12 fps). - - Assign `role="context"` to T2 cameras (8–10 fps). - - Assign `role="perimeter"` to T3 motion-only cameras. +1. **Detector thresholds:** + - `MOTION_THR` (default 0.25) — per-second stillness threshold shared by fall no-motion and + immobility. This is the primary re-tuning knob on real video: it is frame-rate normalised, + so a device running at a different measured fps needs it re-checked. + - `FALL_PRONE_S` (3.0) / `FALL_NO_MOTION_S` (10.0) — how long prone before a fall confirms, + and before it escalates to the 위급 no-motion event. Raise if real footage over-triggers. + - `IMMOBILE_S` (120.0), `GHOST_REAPPEAR_S` (10.0), `ZONE_DEBOUNCE_S` (0.75), `COOLDOWN_S` (30.0). + +2. **Per-camera load:** + - `INFER_EVERY` (1) — 1 for the fall-critical camera, 2–3 for context cameras that missed + the T1 target. Set per device; each camera on a device shares it today. + - `TARGET_FPS` (12) — loop pacing. It only slows the loop down; it cannot buy throughput. 3. **Benchmark report:** - Document actual Pi hardware, kernel version, HailoRT version, Hailo FW version. diff --git a/edge/adapters/uvc_source.py b/edge/adapters/uvc_source.py index 7c9756a..41ae641 100644 --- a/edge/adapters/uvc_source.py +++ b/edge/adapters/uvc_source.py @@ -4,11 +4,17 @@ import cv2 class UvcSource: - def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30, _open=None): + def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30, + size: tuple[int, int] = (640, 480), _open=None): # _open lets tests inject a fake capture; production uses cv2.VideoCapture. self._dev = dev self._mjpeg = mjpeg self._reopen_after = reopen_after + # Pin the capture size to the CSI default. Left unset, a UVC cam opens at ITS default — + # a 1080p webcam yields 6.2MB frames, and the 90-frame evidence ring then costs ~560MB + # per camera (vs ~80MB at 640x480), which is an OOM on a 4-camera Pi. Memory must be a + # property of OUR config, not of whichever webcam the site happened to plug in. + self._size = size self._open = _open or (lambda d: cv2.VideoCapture(d)) self._fail_streak = 0 self.cap = self._open_cap() @@ -22,6 +28,14 @@ class UvcSource: raise RuntimeError(f"UVC device {self._dev!r} failed to open") if self._mjpeg: cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) + w, h = self._size + cap.set(cv2.CAP_PROP_FRAME_WIDTH, w) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, h) + # Ask v4l2 to keep the shallowest queue it will honour. When the loop runs slower than + # the camera's fps, a deep driver queue hands back frames from seconds ago — a fall + # would be detected long after it happened. Not all backends honour this; harmless if + # ignored (the drain-only tick in PipelineRuntime covers the rest). + cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) return cap def read(self): diff --git a/edge/config.py b/edge/config.py index eac65cf..ff3c0a0 100644 --- a/edge/config.py +++ b/edge/config.py @@ -19,12 +19,32 @@ class Config: ghost: GhostConfig zone: ZoneConfig engine: EngineConfig + # Tier-2 degradation knob: run pose on every Nth tick (1 = every tick, the T1 target). + # Raise this when a device cannot sustain full-rate inference across all its cameras — + # it trades reaction granularity for keeping every camera online, which beats going blind + # on one. See docs/hardware-bringup.md. + infer_every: int = 1 def _f(env: dict, key: str, default: float) -> float: return float(env[key]) if key in env else default +def _positive(env: dict, key: str, default: float) -> float: + """A timer/threshold knob that must be a finite positive number. + + Every one of these feeds a `now - started >= limit` comparison. NaN makes that comparison + ALWAYS False and 0/negative makes it ALWAYS True, so a typo in edge.env does not crash — + it silently turns a detector off, or turns it into a permanent alarm. COOLDOWN_S=nan is the + worst case: dedup stops suppressing and every tick re-fires the same event. Reject at + startup, where the operator still sees the message, rather than in the field. + """ + val = _f(env, key, default) + if not math.isfinite(val) or val <= 0: + raise ValueError(f"{key} must be a finite positive number, got {val!r}") + return val + + def frame_interval_from_env(env: dict) -> float: """Seconds per frame from TARGET_FPS, validated. TARGET_FPS=0 (ZeroDivisionError) or a non-numeric value (ValueError) would otherwise crash at every startup into an infinite @@ -98,20 +118,20 @@ def load_config(env: dict) -> Config: # track_displacement is now frame-rate-normalized, so this must be tuned per device — expose # it via env instead of forcing a code edit + redeploy. Reject a non-finite or non-positive # value at startup (a 0 or NaN would make everything read as "still" -> constant no_motion). - motion_thr = _f(env, "MOTION_THR", 0.25) - if not math.isfinite(motion_thr) or motion_thr <= 0: - raise ValueError(f"MOTION_THR must be a finite positive number, got {motion_thr!r}") + motion_thr = _positive(env, "MOTION_THR", 0.25) fall = FallConfig( - prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0), - no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0), + prone_confirm_s=_positive(env, "FALL_PRONE_S", 3.0), + no_motion_s=_positive(env, "FALL_NO_MOTION_S", 10.0), motion_thr=motion_thr, ) - immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0), + immobility = ImmobilityConfig(immobile_s=_positive(env, "IMMOBILE_S", 120.0), motion_thr=motion_thr) - ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0)) - zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75)) - engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0)) + ghost = GhostConfig(reappear_s=_positive(env, "GHOST_REAPPEAR_S", 10.0)) + zone = ZoneConfig(debounce_s=_positive(env, "ZONE_DEBOUNCE_S", 0.75)) + engine = EngineConfig(camera_id=camera_id, cooldown_s=_positive(env, "COOLDOWN_S", 30.0)) + infer_every = int(_positive(env, "INFER_EVERY", 1.0)) return Config( + infer_every=infer_every, device_id=env.get("DEVICE_ID", "pi-unknown"), camera_id=camera_id, cafe24_url=env.get("CAFE24_URL", ""), diff --git a/edge/daemon.py b/edge/daemon.py index 4463a5a..fbcdae9 100644 --- a/edge/daemon.py +++ b/edge/daemon.py @@ -18,7 +18,8 @@ def _default_log(msg, exc=None): # pragma: no cover (thin stderr wrapper) def run_loop(runtime, client, queue, *, clock, sleep, iterations=None, sync_every=150, heartbeat_every=900, flush_every=300, frame_interval=0.0, zones_for=None, log=None, - heartbeat_interval_s=None, health_fn=None, configured_cameras=None): + heartbeat_interval_s=None, health_fn=None, configured_cameras=None, + notify=None): # `runtime` may be a single PipelineRuntime or a list of them (multi-camera). All # cameras share this loop, one shared clock read per tick, and the periodic # sync/heartbeat/flush. A per-camera step exception is counted and rate-limit-logged, @@ -33,6 +34,11 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None, if health_fn is None: from edge.health import read_health health_fn = read_health + if notify is None: + from edge.watchdog import ping as notify + # The ping happens at the TOP of each tick, before any camera work. That is the point: it + # proves the loop is turning. Pinging after the work would still ping while a camera step + # blocks forever, which is the exact failure the watchdog exists to catch. counters = {"frames": 0, "syncs": 0, "beats": 0, "flushes": 0, "errors": 0} streak = {} # id(rt)/"periodic" -> consecutive-error count, for rate-limited logging @@ -52,6 +58,10 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None, i = 0 while iterations is None or i < iterations: i += 1 + try: + notify() + except Exception as e: + note("watchdog", "watchdog ping failed", e) now = clock() # one timestamp per tick, shared across cameras if beat_anchor is None: beat_anchor = (now, 0) diff --git a/edge/queue.py b/edge/queue.py index 271011d..77ced8b 100644 --- a/edge/queue.py +++ b/edge/queue.py @@ -8,10 +8,17 @@ from edge.events import Event, EventType, Severity class OfflineQueue: def __init__(self, db_path: str, client, max_rows: int = 2000, - max_send_attempts: int = 20): + max_send_attempts: int = 20, max_flush_rows: int = 20): self.client = client self.max_rows = max_rows # SD-card guard: evict oldest beyond this self.max_send_attempts = max_send_attempts # drop a poison event after this many tries + # Detection-gap guard. flush() runs INSIDE the single-threaded detection loop, so every + # row it sends is time the camera is not being processed. Draining a full max_rows queue + # in one call (2000 rows x up to 5s POST timeout) would stall fall detection for minutes + # right after an outage recovers — exactly when the backlog proves the site was + # unmonitored. Drain a slice per tick instead: the queue still empties, but the loop keeps + # detecting between slices. + self.max_flush_rows = max_flush_rows self.db = sqlite3.connect(db_path) self.db.execute( "CREATE TABLE IF NOT EXISTS queue (" @@ -60,8 +67,11 @@ class OfflineQueue: return False def flush(self) -> int: + # LIMIT bounds BOTH the stall above and peak memory: each row carries up to 3 base64 + # JPEGs, so fetching the whole queue would also spike RSS on the Pi. rows = self.db.execute( - "SELECT id, payload, snaps, attempts FROM queue ORDER BY id").fetchall() + "SELECT id, payload, snaps, attempts FROM queue ORDER BY id LIMIT ?", + (self.max_flush_rows,)).fetchall() sent = 0 for rid, payload_json, snaps_json, attempts in rows: try: diff --git a/edge/runtime.py b/edge/runtime.py index 70d9a57..b0e9dab 100644 --- a/edge/runtime.py +++ b/edge/runtime.py @@ -16,6 +16,12 @@ class PipelineRuntime: self.source = source self.pose = pose self.sink = sink + # Tier-2 fallback (hardware-bringup.md): when the device cannot sustain full-rate pose + # for every camera, infer on every Nth tick instead of dropping cameras. Detector timers + # are wall/monotonic-based, not frame-count-based, so they stay correct at a lower + # sampling rate — only the reaction granularity coarsens. + self.infer_every = max(1, int(getattr(config, "infer_every", 1))) + self._tick = 0 # Wall clock for Event.occurred_at only; the loop clock (step's `now`) is monotonic. None # off-daemon -> run_once stamps occurred_at with the monotonic `now`, preserving old tests. self._wall = wall_clock @@ -33,6 +39,16 @@ class PipelineRuntime: self.snapshotter = Snapshotter(FaceBlurrer()) def step(self, now: float) -> list: + self._tick += 1 + if self.infer_every > 1 and self._tick % self.infer_every != 0: + # Drain-only tick. Pull the frame so the v4l2/CSI buffer stays current — a backed-up + # buffer hands back seconds-old frames and delays every alert — then DISCARD it. + # It must NOT reach the ring buffer: head boxes come from inference, so an + # un-analyzed frame carries no blur regions and would ship an identifiable face in + # the evidence timeline (PIPA). Returning [] (not None) keeps this a healthy tick + # for the daemon's camera-health accounting; a dead camera still reports None below. + ok, _ = self.source.read() + return [] if ok else None return run_once(self.source, self.pose, self.tracker, self.pool, self.engine, self.ring, self.snapshotter, self.sink, now_fn=lambda: now, wall_fn=self._wall) diff --git a/edge/watchdog.py b/edge/watchdog.py new file mode 100644 index 0000000..900512b --- /dev/null +++ b/edge/watchdog.py @@ -0,0 +1,48 @@ +"""systemd watchdog ping (sd_notify), stdlib-only. + +Restart=always only catches a process that DIES. It cannot catch a process that is alive but +stuck — a v4l2 read blocking on a wedged USB controller, a HailoRT call that never returns, a +sqlite lock. In that state the daemon keeps its PID, stops detecting, and the only signal is the +dashboard turning the device offline 120s later: an unattended site then waits for a human to +notice. WatchdogSec + this ping turns that into an automatic restart. + +No pysystemd dependency (the air-gapped bundle carries only wheels we vendor): the protocol is +one datagram to $NOTIFY_SOCKET. +""" + +import os +import socket + + +def sd_notify(state: str) -> bool: + """Send a status datagram to systemd. Returns False when not running under systemd + (NOTIFY_SOCKET unset) or on any socket error — never raises, because a telemetry failure + must not take down detection.""" + addr = os.environ.get("NOTIFY_SOCKET") + if not addr: + return False + # AF_UNIX does not exist on Windows, where developers run the test suite. Guard rather than + # let an AttributeError escape a function whose whole contract is "never raises". + if not hasattr(socket, "AF_UNIX"): + return False + # Abstract namespace sockets start with '@' on Linux; the kernel expects a NUL byte. + if addr.startswith("@"): + addr = "\0" + addr[1:] + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock: + sock.connect(addr) + sock.sendall(state.encode()) + return True + except OSError: + return False + + +def ping() -> bool: + """One 'I am still looping' keepalive.""" + return sd_notify("WATCHDOG=1") + + +def ready() -> bool: + """Announce startup completion. Harmless under Type=exec; required if the unit ever + switches to Type=notify.""" + return sd_notify("READY=1") diff --git a/server/config.example.php b/server/config.example.php index 18bcb13..f049413 100644 --- a/server/config.example.php +++ b/server/config.example.php @@ -13,4 +13,5 @@ return [ 'ingest_ip_rate_limit_per_min' => 300, // PIPA retention: worker snapshots are purged after this many days (0 disables). 'snapshot_retention_days' => 7, + 'event_retention_days' => 0, // 0 = 이벤트 기록 영구 보존 (안전사고 기록; 스냅샷만 만료) ]; diff --git a/server/deploy/build_cafe24_package.sh b/server/deploy/build_cafe24_package.sh index e698ade..efd9494 100755 --- a/server/deploy/build_cafe24_package.sh +++ b/server/deploy/build_cafe24_package.sh @@ -151,6 +151,15 @@ cp -r "$SERVER/migrations" "$PRIV/migrations" # The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one. cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess" cp "$SERVER/deploy/config.template.php" "$PRIV/config.php" +# Provenance stamp. A package's own contents are always self-consistent, so nothing inside it +# reveals that it was built before the last four feature commits — which is exactly how a stale +# package once shipped. Keep it OUT of the web root: it names the build, not a public asset. +{ + echo "package: move-detect-cafe24" + echo "version: $(cat "$REPO_ROOT/VERSION" 2>/dev/null || echo unknown)" + echo "git_commit: $(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)" + echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)" +} > "$PRIV/BUILD-INFO.txt" cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php" # .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On). cp "$SERVER/deploy/user.ini" "$WEB/.user.ini" diff --git a/server/deploy/config.template.php b/server/deploy/config.template.php index 85ef22f..73bd439 100644 --- a/server/deploy/config.template.php +++ b/server/deploy/config.template.php @@ -28,4 +28,8 @@ return [ 'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한 'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어) 'snapshot_retention_days' => 7, // 작업자 스냅샷 보존일 (PIPA; 지나면 삭제, 0=비활성) + // 이벤트 '기록'(이미지 아님) 보존일. 0 = 영구 보존이 기본값입니다 — 산업안전 조사나 + // 노사 분쟁에서 수년 뒤에도 필요할 수 있는 사고 기록이라, 시스템이 임의로 지우지 않습니다. + // DB 용량 제한이 있는 현장만 값을 넣으세요(예: 1095 = 3년). 스냅샷 보존일보다 훨씬 커야 합니다. + 'event_retention_days' => 0, ]; diff --git a/server/public/assets/app.js b/server/public/assets/app.js index f07612d..7a56b24 100644 --- a/server/public/assets/app.js +++ b/server/public/assets/app.js @@ -254,12 +254,17 @@ var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'ack-btn'; + // data-focus keys the row's controls so renderList can hand focus back to the SAME + // button after a poll rebuild (see renderList). Without it a keyboard operator loses + // focus to
every 5 seconds, mid-triage. + btn.setAttribute('data-focus', 'ack:' + String(ev.event_id)); btn.textContent = '확인'; btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); }); actions.appendChild(btn); var fpBtn = document.createElement('button'); fpBtn.type = 'button'; fpBtn.className = 'fp-btn'; + fpBtn.setAttribute('data-focus', 'fp:' + String(ev.event_id)); fpBtn.textContent = '오탐'; fpBtn.setAttribute('aria-label', '오탐으로 표시 (확인 처리됨, 오탐 통계에 집계)'); fpBtn.addEventListener('click', function () { markFalsePositive(ev.event_id, fpBtn, btn); }); @@ -397,6 +402,9 @@ var pagedByUser = false; // once true, polls stop resetting moreCursor to the live boundary var newestIdAtPaging = null; // newest live event_id at the moment paging began (gap detection) var pageGapHint = null; // lazily-built "new events arrived while paging" reload affordance + var lastListSig = null; // last rendered list fingerprint; unchanged -> keep the DOM (and focus) + var eventsInFlight = false; // guards against overlapping polls on a slow shared host + var loadedOnce = false; // distinguishes "cold start failed" from "refresh went stale" // The camera filter is a (device, camera) PAIR, not a bare camera_id: camera ids are free-form // strings from the edge, so two devices may both run a "cam-01". Selecting the pair is what the // rail tree hands us, and it keeps one device's events out of another's filtered view. @@ -437,10 +445,25 @@ return (b.event_id || 0) - (a.event_id || 0); }); + // Skip the rebuild when nothing visible changed. #events-root is an aria-live region and + // this runs every 5s: rebuilding it unconditionally made a screen reader re-announce all + // 200 rows on every poll, and threw keyboard focus to mid-triage. Same signature + + // refocus pattern device-tree.js already uses for the rail. + var anyFilter = filters.severity || filters.type || filters.camera || filters.unackedOnly; + var sig = shown.map(function (ev) { + return [ev.event_id, ev.acknowledged_by || '', ev.false_positive_by || '', + ev.thumb_uuid || ''].join('|'); + }).join(',') + '#' + String(moreCursor === null) + '#' + String(!!anyFilter); + if (sig === lastListSig) return; + lastListSig = sig; + + // Rows are rebuilt, not patched, so remember which control held focus and restore it. + var active = document.activeElement; + var refocus = (active && root.contains(active)) ? active.getAttribute('data-focus') : null; + root.textContent = ''; root.removeAttribute('aria-busy'); if (shown.length === 0) { - var anyFilter = filters.severity || filters.type || filters.camera || filters.unackedOnly; var empty = document.createElement('div'); empty.className = 'stream-empty'; empty.textContent = anyFilter @@ -452,6 +475,10 @@ shown.forEach(function (ev) { frag.appendChild(buildRow(ev)); }); root.appendChild(frag); } + if (refocus) { + var back = root.querySelector('[data-focus="' + refocus + '"]'); + if (back) back.focus(); + } if (moreBtn) moreBtn.hidden = (moreCursor === null); updatePageGapHint(); } @@ -479,6 +506,11 @@ } function loadEvents() { + // In-flight guard. This runs on a fixed 5s interval against Cafe24 shared hosting; if a + // response takes longer than the interval, unguarded polls stack up and — worse — an older + // response can land after a newer one and overwrite the list with stale rows. + if (eventsInFlight) return Promise.resolve(); + eventsInFlight = true; // Newest window (server MAX_LIMIT=200). The audible alarm and the "unacknowledged first" // pinning only consider this set, so it stays the full cap — during an event burst a smaller // page could push an unacknowledged critical off it and updateAlarm() would clear the alarm @@ -497,12 +529,27 @@ if (!pagedByUser) moreCursor = data.next_cursor || null; renderList(); } + loadedOnce = true; setWatch(true); }) .catch(function () { // Leave the last rendered state on transient poll failure, but flag the staleness. setWatch(false); - }); + // Cold start is different from a stale refresh: if the FIRST load never succeeded there + // is no last-good list, and the skeleton "불러오는 중…" would sit there forever looking + // like a slow page rather than a broken one — on a safety console, silence must not be + // mistaken for calm. + if (!loadedOnce && root) { + root.removeAttribute('aria-busy'); + root.textContent = ''; + var fail = document.createElement('div'); + fail.className = 'stream-empty'; + fail.textContent = '이벤트를 불러오지 못했습니다. 서버 연결을 확인하세요 — 재시도 중입니다.'; + root.appendChild(fail); + lastListSig = null; // let the next successful poll rebuild the list + } + }) + .then(function () { eventsInFlight = false; }); } function loadMore() { diff --git a/server/public/index.php b/server/public/index.php index 11575aa..f0b0ca6 100644 --- a/server/public/index.php +++ b/server/public/index.php @@ -104,7 +104,9 @@ $actionsCtl = new ActionsController(new PdoActionRepository($pdo), $events, $ses $render = function (string $view): Response { ob_start(); require APP_ROOT . '/src/View/' . $view . '.php'; - return Response::text((string)ob_get_clean()); + // Security headers travel with the HTML response (not only the packaged .htaccess) so the + // CSP nonce the views just emitted is the one the browser is told to trust. + return Response::text((string)ob_get_clean(), 200, \App\Security\Csp::headers()); }; $r = new Router(); @@ -113,7 +115,7 @@ $r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now, ( $r->add('GET', '/api/config', fn(Request $q) => $config->get($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $r->add('GET', '/api/csrf', fn(Request $q) => $csrfCtl->token($q, $sess)); $r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); -$r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess)); +$r->add('POST', '/logout', fn(Request $q) => $auth->logout($q, $sess)); $r->add('GET', '/api/events', fn(Request $q) => $list->json($q)); $r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now)); $r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p)); diff --git a/server/src/Auth/AuthController.php b/server/src/Auth/AuthController.php index 4044f5b..6ab3b47 100644 --- a/server/src/Auth/AuthController.php +++ b/server/src/Auth/AuthController.php @@ -45,8 +45,12 @@ final class AuthController } if ((int)($u['locked_until'] ?? 0) > $now) { + // Same 401/invalid_credentials as an unknown user. A distinct 423 was a username + // oracle: anyone could lock a guessed name with 5 bad tries, then read "locked" as + // confirmation that the account is real. The lockout is still recorded in the audit + // log, which is where an admin — not an anonymous caller — learns about it. $this->audit->append($username, 'login_locked', 'auth', [], []); - return Response::json(['error' => 'locked'], 423); + return Response::json(['error' => 'invalid_credentials'], 401); } // lock window expired -> reset stale failure counter so the user gets a fresh set of attempts @@ -73,8 +77,17 @@ final class AuthController return Response::json(['ok' => true], 200); } - public function logout(SessionStore $sess): Response + public function logout(Request $req, SessionStore $sess): Response { + // CSRF-checked like every other state-changing route. SameSite=Lax already blocks the + // cross-site POST, but a forced logout is a real availability action on THIS product — + // knocking an operator out of the console mid-incident — so it does not get to be the + // one exception to the rule. Only enforced for an actual session: an already-logged-out + // caller (no csrf seeded) must still get a clean 200 rather than a confusing 403. + $expected = (string)$sess->get('csrf'); + if ($expected !== '' && !Csrf::check($expected, (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } // Rotate the id FIRST (invalidates the old cookie) while the session is still active, // then wipe. The old order called session_regenerate_id() AFTER session_destroy(), // which warns on an inactive session and leaves the old cookie unreplaced. diff --git a/server/src/Auth/Session.php b/server/src/Auth/Session.php index 8aa1cbe..402debc 100644 --- a/server/src/Auth/Session.php +++ b/server/src/Auth/Session.php @@ -14,7 +14,13 @@ final class ArraySessionStore implements SessionStore { public function get(string $k): mixed { return $this->d[$k] ?? null; } public function set(string $k, mixed $v): void { $this->d[$k] = $v; } public function clear(): void { $this->d = []; } - public function regenerate(): void { $this->d = []; } + /** + * Rotates the id only — the DATA survives, matching PhpSessionStore's + * session_regenerate_id(true). This double used to wipe the array, which diverged from the + * real store and hid a live question: whether the csrf token seeded before login is still + * there after it (it is — that is what /logout's CSRF check relies on). + */ + public function regenerate(): void { /* id is not modeled; data is preserved */ } } final class PhpSessionStore implements SessionStore { diff --git a/server/src/Config.php b/server/src/Config.php index d56ed72..b249fcc 100644 --- a/server/src/Config.php +++ b/server/src/Config.php @@ -17,4 +17,13 @@ final class Config public function rateLimitPerMin(): int { return (int)($this->a['rate_limit_per_min'] ?? 60); } public function ingestIpRateLimitPerMin(): int { return (int)($this->a['ingest_ip_rate_limit_per_min'] ?? 300); } public function snapshotRetentionDays(): int { return (int)($this->a['snapshot_retention_days'] ?? 7); } + /** + * Retention for the event ROWS (not the images). Default 0 = keep forever, deliberately: + * these are workplace safety incident records, and an occupational-safety investigation or + * a labour dispute may need them years later — silently deleting them by default would be + * destroying evidence on the operator's behalf. The images are a different matter and DO + * expire (snapshotRetentionDays), because those are the personal data PIPA covers. + * A site with a DB quota can opt in by setting this. + */ + public function eventRetentionDays(): int { return (int)($this->a['event_retention_days'] ?? 0); } } diff --git a/server/src/Http/Response.php b/server/src/Http/Response.php index bcc18c3..b3b5815 100644 --- a/server/src/Http/Response.php +++ b/server/src/Http/Response.php @@ -9,8 +9,9 @@ final class Response public static function json(array $data, int $status = 200): self { return new self($status, ['Content-Type' => 'application/json'], json_encode($data, JSON_UNESCAPED_UNICODE)); } - public static function text(string $s, int $status = 200): self { - return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s); + public static function text(string $s, int $status = 200, array $headers = []): self { + // Content-Type stays authoritative; extra headers (e.g. Content-Security-Policy) are added. + return new self($status, ['Content-Type' => 'text/html; charset=utf-8'] + $headers, $s); } public static function bytes(string $b, string $contentType, int $status = 200, array $headers = []): self { // Content-Type stays authoritative; extra headers (e.g. Cache-Control) are added, not overridden. diff --git a/server/src/Ingest/IngestController.php b/server/src/Ingest/IngestController.php index 1320187..873a13f 100644 --- a/server/src/Ingest/IngestController.php +++ b/server/src/Ingest/IngestController.php @@ -95,8 +95,16 @@ final class IngestController // PIPA retention: purge worker snapshots older than the configured window. Runs rarely // (the gate) and off the critical path — the event is already stored. $days = $this->cfg->snapshotRetentionDays(); - if ($days > 0 && ($this->purgeGate)()) { - $this->snapshots->purgeOlderThan($now - $days * 86400); + $eventDays = $this->cfg->eventRetentionDays(); + if (($days > 0 || $eventDays > 0) && ($this->purgeGate)()) { + if ($days > 0) { + $this->snapshots->purgeOlderThan($now - $days * 86400); + } + // Event rows: opt-in only (default 0 = keep forever). Runs AFTER the snapshot purge + // and on a much longer window, so images are always gone before their event row is. + if ($eventDays > 0) { + $this->events->purgeOlderThan((float)($now - $eventDays * 86400)); + } } return Response::json(['event_id' => $eventId], 201); } diff --git a/server/src/Repository/EventRepository.php b/server/src/Repository/EventRepository.php index f9e1560..d3f4478 100644 --- a/server/src/Repository/EventRepository.php +++ b/server/src/Repository/EventRepository.php @@ -66,6 +66,12 @@ interface EventRepository * so it is empty in every deployment — checking it would reject every real camera. */ public function cameraExists(string $cameraId): bool; + /** + * Delete event rows older than $cutoff, at most $limit per call. Opt-in only + * (Config::eventRetentionDays defaults to 0 = never) — see that method for why safety + * incident rows are not expired by default. Returns the number deleted. + */ + public function purgeOlderThan(float $cutoff, int $limit = 500): int; /** * The site this camera's events are recorded under (from its most recent event), or null * when the camera is unknown OR its device has no site assigned. Pair with cameraExists() diff --git a/server/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php index e92ade7..72d2ea9 100644 --- a/server/src/Repository/Pdo/PdoEventRepository.php +++ b/server/src/Repository/Pdo/PdoEventRepository.php @@ -336,6 +336,26 @@ final class PdoEventRepository implements EventRepository return (int)$st->fetchColumn(); } + public function purgeOlderThan(float $cutoff, int $limit = 500): int + { + // Bounded batch, same shape as the snapshot purge: a shared-hosting request must not + // stall deleting a year of rows. ON DELETE CASCADE on move_event_snapshots would drop + // snapshot ROWS with the event, so purge only what the snapshot purge has already + // emptied of files — cutoff is expected to be far older than the image retention window. + $sel = $this->pdo->prepare( + 'SELECT event_id FROM move_events WHERE occurred_at < ? ORDER BY event_id LIMIT ?'); + $sel->bindValue(1, sprintf('%.6f', $cutoff), PDO::PARAM_STR); + $sel->bindValue(2, $limit, PDO::PARAM_INT); + $sel->execute(); + $ids = array_map('intval', $sel->fetchAll(PDO::FETCH_COLUMN)); + if (!$ids) { + return 0; + } + $in = implode(',', array_fill(0, count($ids), '?')); + $this->pdo->prepare("DELETE FROM move_events WHERE event_id IN ($in)")->execute($ids); + return count($ids); + } + public function cameraExists(string $cameraId): bool { $st = $this->pdo->prepare('SELECT 1 FROM move_events WHERE camera_id = ? LIMIT 1'); diff --git a/server/src/Security/Csp.php b/server/src/Security/Csp.php new file mode 100644 index 0000000..2442713 --- /dev/null +++ b/server/src/Security/Csp.php @@ -0,0 +1,66 @@ + blocks in layout.php (APP_BASE/APP_USER bootstrap, logout wiring) + * carry a per-request nonce rather than being unsafe-inline'd: "no build step" prevents + * bundling them into a file, but it does not prevent PHP from emitting a nonce. + * + * Deliberately NOT in .htaccess: the nonce changes every request, and Apache cannot know it. + * Keeping the whole header set here also means the dev server and the delivered package get + * identical headers — previously only the packaged .htaccess had them, so a header regression + * was invisible in development. + */ +final class Csp +{ + private static ?string $nonce = null; + + /** Per-request nonce, generated once and reused for every inline script on the page. */ + public static function nonce(): string + { + return self::$nonce ??= base64_encode(random_bytes(16)); + } + + /** Reset between tests; a fresh request would otherwise reuse the previous nonce. */ + public static function reset(): void + { + self::$nonce = null; + } + + public static function policy(string $nonce): string + { + return implode('; ', [ + "default-src 'self'", + "script-src 'self' 'nonce-$nonce'", + "style-src 'self'", // one external stylesheet; no