From 9aa7f44ea4accfaeb67d6fab4270d34e39ab2e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sun, 12 Jul 2026 01:58:05 +0900 Subject: [PATCH] feat(cloud): PDO repositories + schema + Cafe24 deploy doc Implements Task 16 (final, P1-B): App\Db\Database::connect(Config): PDO factory and the 9 App\Repository\Pdo\Pdo*Repository classes backing the repository interfaces consumed by controllers since T1-T15, all-prepared statements, plus cloud/schema.sql (MySQL 8 DDL) and cloud/docs/cloud-deploy.md. Security hardening incorporated per accumulated task reviews: - PdoNonceRepository: nonces.nonce is PK; duplicate remember() throws instead of racing seen() (closes T7 TOCTOU). - PdoRateLimiterRepository::hit(): atomic INSERT ... ON DUPLICATE KEY UPDATE upsert on (rate_key, window_start), no read-modify-write race. - PdoSnapshotRepository::pathByUuid(): validates 32-hex uuid before building any filesystem path (closes T12 path-traversal risk). - PdoZoneRepository::save(): update path is ownership-scoped by (zone_id, device_id, camera_id); throws on mismatch instead of silently overwriting an unrelated zone (closes T13 cross-tenant overwrite risk). - Database::connect(): ERRMODE_EXCEPTION, EMULATE_PREPARES=false, FETCH_ASSOC. cloud/tests/PdoIntegrationTest.php round-trips Nonce/Event/Zone against a real sqlite :memory: DB, gated via markTestSkipped when pdo_sqlite is unavailable (this host has pdo_mysql only, no pdo_sqlite) -- suite stays green with 4 tests skipped. Full suite: 59 tests, 114 assertions, 4 skipped, 0 failures, 0 warnings/deprecations. php -l passes on all new files and on cloud/public/index.php (T15), which references all new FQCNs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1 --- cloud/docs/cloud-deploy.md | 239 ++++++++++++++++++ cloud/schema.sql | 219 ++++++++++++++++ cloud/src/Db/Database.php | 18 ++ .../src/Repository/Pdo/PdoAuditRepository.php | 27 ++ .../Repository/Pdo/PdoDeviceRepository.php | 50 ++++ .../src/Repository/Pdo/PdoEventRepository.php | 105 ++++++++ .../src/Repository/Pdo/PdoNonceRepository.php | 33 +++ .../Pdo/PdoRateLimiterRepository.php | 32 +++ .../Repository/Pdo/PdoSnapshotRepository.php | 50 ++++ .../Repository/Pdo/PdoThresholdRepository.php | 23 ++ .../src/Repository/Pdo/PdoUserRepository.php | 49 ++++ .../src/Repository/Pdo/PdoZoneRepository.php | 91 +++++++ cloud/tests/PdoIntegrationTest.php | 169 +++++++++++++ 13 files changed, 1105 insertions(+) create mode 100644 cloud/docs/cloud-deploy.md create mode 100644 cloud/schema.sql create mode 100644 cloud/src/Db/Database.php create mode 100644 cloud/src/Repository/Pdo/PdoAuditRepository.php create mode 100644 cloud/src/Repository/Pdo/PdoDeviceRepository.php create mode 100644 cloud/src/Repository/Pdo/PdoEventRepository.php create mode 100644 cloud/src/Repository/Pdo/PdoNonceRepository.php create mode 100644 cloud/src/Repository/Pdo/PdoRateLimiterRepository.php create mode 100644 cloud/src/Repository/Pdo/PdoSnapshotRepository.php create mode 100644 cloud/src/Repository/Pdo/PdoThresholdRepository.php create mode 100644 cloud/src/Repository/Pdo/PdoUserRepository.php create mode 100644 cloud/src/Repository/Pdo/PdoZoneRepository.php create mode 100644 cloud/tests/PdoIntegrationTest.php diff --git a/cloud/docs/cloud-deploy.md b/cloud/docs/cloud-deploy.md new file mode 100644 index 0000000..1535d9c --- /dev/null +++ b/cloud/docs/cloud-deploy.md @@ -0,0 +1,239 @@ +# Cafe24 Cloud Deploy Guide (P1-B) + +This document covers deploying the `cloud/` PHP application (ingest API + dashboard) to a +Cafe24 shared/managed PHP hosting plan. The application has **zero runtime Composer +dependencies** (PHPUnit is dev-only) and speaks to MySQL exclusively through `PDO` with +prepared statements — there is nothing to `composer install` on the production host. + +## 1. Directory layout on the server + +Only `cloud/public/` may be reachable from the web. Everything else must sit **outside** +the web root (one level up, or wherever your Cafe24 plan's "web root" / "html" folder is), +or be blocked by hosting-level access rules if your plan cannot place files outside the +web root at all. + +``` +/home// <- NOT web-reachable +├── src/ <- App\ classes (App\Db, App\Repository\Pdo, ...) +├── storage/ +│ └── snapshots/ <- re-encoded JPEGs; must be writable by the PHP process +├── config.php <- copied from config.example.php, secrets filled in (see §3) +├── schema.sql <- only needed transiently for the phpMyAdmin import (§4); +│ delete it from the server afterwards +└── public/ <- web root ("html" folder in Cafe24's control panel) + ├── index.php + ├── .htaccess + └── assets/ +``` + +`cloud/public/index.php` resolves its dependencies as `__DIR__ . '/../config.php'`, +`__DIR__ . '/../src/View/...'`, etc. — so `src/`, `config.php`, and `storage/` must be +exactly one directory above `public/`, matching the layout above. + +**Do not upload `vendor/`.** It contains only `phpunit` and its dev dependencies (see +`cloud/composer.json` — `require-dev` only, `require` has no packages beyond the `php` +version constraint). Shipping it would put a large, unused, occasionally-vulnerable +dependency tree on a production box for no benefit; leave it off the transfer entirely. + +If your Cafe24 plan cannot place folders outside the web root, upload `src/`, `storage/`, +and `config.php` into `public/` but add explicit deny rules for them in +`public/.htaccess` (e.g. `RewriteRule ^(src|storage)/ - [F]` and +`RewriteRule ^config\.php$ - [F]`, placed **before** the existing rewrite-to-index rule) — +placing them outside the web root is strongly preferred and should be the default choice. + +## 2. Host prerequisites — verify before importing data + +Check these on the actual Cafe24 PHP build (a `phpinfo()` page or `php -m` / `php -v` over +SSH if available) — do not assume, since PHP builds vary by hosting plan: + +- [ ] **PHP version** matches `cloud/composer.json`'s `"php": ">=8.2"` (the app is written + and tested against PHP 8.5; anything ≥ 8.2 should run it, but confirm the exact + version Cafe24 provisions for your plan). +- [ ] **`pdo_mysql`** extension is enabled (`PDO::getAvailableDrivers()` must include + `mysql`). +- [ ] **`gd`** extension is enabled — `App\Ingest\ImageProcessor::reencode()` calls + `imagecreatefromstring()` / `imagejpeg()` to re-encode uploaded snapshots; without + `gd`, every event with a snapshot silently drops that snapshot (`reencode()` returns + `['ok' => false]` and the caller just `continue`s). +- [ ] **`openssl`** extension is enabled — required for `random_bytes()`'s CSPRNG backing + and TLS to the DB/clients on most builds; effectively always present, but confirm. +- [ ] **`PASSWORD_ARGON2ID` support** (the `argon2` build flag on PHP's `password_hash()`). + Run: + ```php + 'mysql:host=localhost;dbname=;charset=utf8mb4', + 'db_user' => '', + 'db_pass' => '', + 'snapshot_dir' => __DIR__ . '/storage/snapshots', + 'nonce_ttl' => 86400, + 'timestamp_window' => 300, + 'rate_limit_per_min' => 60, +]; +``` + +Get the DB host/user/password/database name from the Cafe24 hosting control panel's MySQL +section. Do not reuse the DB password anywhere else, and do not paste it into chat, tickets, +or version control. + +## 4. Import the schema + +`cloud/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`, +`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`). + +1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this + app. +2. **Import** tab → choose file → upload `schema.sql` → Go. +3. Confirm all 10 tables were created (phpMyAdmin's table list on the left, or + `SHOW TABLES;` in the SQL tab). +4. Delete `schema.sql` from the server once the import succeeds — it doesn't need to live + on the host afterwards, and leaving DDL lying around outside the web root is unnecessary + attack surface for no benefit. + +If your Cafe24 MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL +(on `role`, `event_type`, `severity`, `level`) will be parsed but silently not enforced — +harmless, since the application already whitelists these values before every insert +(`App\Ingest\EventValidator`, `App\Auth\Rbac`, `App\Dashboard\ZoneConfigController::LEVELS`). +Everything else in the schema (tables, keys, indexes, `JSON` columns) requires only +MySQL 5.7+. + +## 5. Storage directory + +Create the snapshot directory and make it writable by the PHP process (the account PHP-FPM +or mod_php runs as on your Cafe24 plan — typically your hosting account's own user, so a +plain `chmod` from your account is usually sufficient; confirm via a test upload): + +```bash +mkdir -p storage/snapshots +chmod 750 storage/snapshots +``` + +`storage/snapshots/` must sit **outside** the web root (§1) — snapshots are only ever +served through the authenticated `GET /snap/{uuid}` route (`App\Dashboard\SnapshotProxy`), +never as static files. If `storage/` were reachable directly under `public/`, every +snapshot would be an unauthenticated static download by UUID. + +## 6. Per-device secrets + +There is no self-registration flow for edge devices in P1-B — an admin provisions each +`devices` row (and its HMAC secret) directly, before that device is allowed to sync +config/heartbeat or ingest events (`App\Repository\Pdo\PdoDeviceRepository::secretFor()` +is the single gate all three ingest-side HMAC checks depend on). + +Generate a secret and insert the device row via phpMyAdmin's SQL tab (or CLI, if available): + +```bash +php -r "echo bin2hex(random_bytes(32)), \"\n\";" +``` + +```sql +INSERT INTO devices (device_id, name, secret, config_version) +VALUES ('pi-01', 'Line 3 — Fall Cam', '', 1); +``` + +Deliver the secret to the device provisioning process out-of-band (not email/chat) and +configure it as the device's `HMAC` signing key for `X-Signature` on +`/api/events`, `/api/heartbeat`, and `/api/config`. Rotate a compromised secret by +`UPDATE devices SET secret = ? WHERE device_id = ?` with a freshly generated value — the +old value stops working immediately since `secretFor()` always reads the current row. + +## 7. First admin account + +`App\Repository\UserRepository` has no `create()` method in P1-B (accounts aren't created +through the app UI) — create the first admin directly via phpMyAdmin, using a CLI snippet +to generate the Argon2id hash so the plaintext password never touches the SQL you paste +into phpMyAdmin's history: + +```bash +php -r "echo password_hash('CHANGE-ME-to-a-real-passphrase', PASSWORD_ARGON2ID), \"\n\";" +``` + +(If this errors with an undefined-constant fatal, your PHP build lacks Argon2 — go back to +§2 before continuing; do not fall back to `PASSWORD_DEFAULT`/bcrypt without updating +`App\Security\Password::hash()`/`verify()` to match, since they're hardcoded to +`PASSWORD_ARGON2ID`.) + +```sql +INSERT INTO users (username, password_hash, role, site_scope, failed_attempts, locked_until) +VALUES ('admin', '', 'admin', NULL, 0, 0); +``` + +Log in at `/login`, then change the password by re-running the CLI snippet with a new +passphrase and updating the row (there's no self-service "change password" flow in P1-B). + +## 8. HTTPS / HSTS + +The dashboard sets `cookie_secure => true` on its session cookie +(`App\Auth\Session::PhpSessionStore`) — **the session cookie will not be sent by the +browser over plain HTTP at all**, so the dashboard is non-functional without HTTPS; this +isn't optional hardening, it's a hard requirement for login to work. + +- [ ] Enable Cafe24's free TLS certificate (or upload your own) for the domain. +- [ ] Force HTTP → HTTPS redirect (Cafe24 control panel setting, or add a rewrite rule + in `public/.htaccess` above the existing rewrite-to-index rule). +- [ ] Add HSTS via `public/.htaccess`: + ```apache + Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" + ``` + (requires `mod_headers`; most Cafe24 Apache plans have it enabled by default — + verify the header actually appears on a response before relying on it). +- [ ] Set `session.cookie_secure = 1` at the PHP level too if your Cafe24 plan lets you + set `php.ini`/`.user.ini` overrides, as defense-in-depth alongside the explicit + `cookie_secure` option already passed in code. + +## 9. Error display (do not leak stack traces) + +Set in `php.ini` or a `.user.ini` in `public/` (whichever your Cafe24 plan supports): + +```ini +display_errors = Off +log_errors = On +error_log = /home//logs/php_errors.log ; outside the web root +``` + +Several `Pdo*Repository` methods intentionally `throw` on invariant violations that +should never happen in normal operation (e.g. `PdoZoneRepository::save()` throws +`RuntimeException` if a caller supplies a `zone_id` that doesn't belong to the given +`device_id`/`camera_id`, to prevent one device's zones being silently overwritten by +another's). With `display_errors` off, an uncaught exception like that surfaces to the +client as a bare 500 with no body/trace, which is the intended behavior — turning +`display_errors` on in production would leak file paths and internal structure instead. + +## 10. Post-deploy smoke check + +- [ ] `GET /login` renders over HTTPS. +- [ ] Log in with the admin account from §7; dashboard (`/`) loads and polls + `/api/events`. +- [ ] `POST /api/heartbeat` from a real (or curl-simulated, correctly-signed) device using + the secret from §6 returns `200 {"ok":true}`. +- [ ] A snapshot uploaded via `/api/events` is retrievable at `/snap/{uuid}` only while + logged in, and `404`s for a made-up UUID. +- [ ] `storage/snapshots/`, `src/`, `config.php`, and `schema.sql` (if not yet deleted per + §4) are **not** reachable over HTTP — e.g. `curl -I https:///../config.php` + and `curl -I https:///storage/snapshots/` should not return the file/listing. diff --git a/cloud/schema.sql b/cloud/schema.sql new file mode 100644 index 0000000..80d44ae --- /dev/null +++ b/cloud/schema.sql @@ -0,0 +1,219 @@ +-- cloud/schema.sql +-- MySQL 8.0+ DDL for the P1-B cloud ingest + dashboard subset +-- (backing store for App\Repository\Pdo\* -- see cloud/src/Repository/Pdo/). +-- +-- Scope: this is the P1-B subset of the full design-spec schema (design doc §7.4). +-- Tables/columns that belong to later phases are intentionally OMITTED here (YAGNI): +-- sites, escalations, oncall, compliance, retention_policy, audit hash-chaining +-- (prev_hash/hash on audit_log), and events.disposition/is_test/legal_hold/lifecycle_state. +-- See docs/superpowers/specs/2026-07-11-workplace-safety-ai-cctv-design.md §7.4 and the +-- Task 16 brief self-review for the P1-B / P2 / P3 split. +-- +-- sqlite compatibility notes (cloud/tests/PdoIntegrationTest.php uses its own minimal +-- sqlite-flavored DDL for the tables it round-trips -- this file targets MySQL only, +-- these notes exist to explain the mechanical translation if that ever needs to grow): +-- * `INT ... AUTO_INCREMENT` -> sqlite: `INTEGER PRIMARY KEY AUTOINCREMENT` (must be the +-- table's sole INTEGER PRIMARY KEY column) +-- * `ENGINE=InnoDB` -> sqlite: omit (no storage engines) +-- * `DEFAULT CHARSET=...` -> sqlite: omit +-- * `JSON` column type -> sqlite: `TEXT` (no native JSON type; app always +-- reads/writes JSON-encoded strings either way) +-- * `DEFAULT (UNIX_TIMESTAMP())` -> sqlite: `DEFAULT (strftime('%s','now'))` +-- * `INSERT ... ON DUPLICATE KEY UPDATE` (rate_limits upsert) -> sqlite: +-- `INSERT ... ON CONFLICT(rate_key, window_start) +-- DO UPDATE SET count = count + 1` +-- * `CHECK (...)` on enum-like columns -> sqlite supports CHECK natively, no change +-- * `TINYINT(1)` -> sqlite: `INTEGER` (0/1) +-- * `BIGINT UNSIGNED` -> sqlite: `INTEGER` (sqlite ints are always 64-bit signed) +-- +-- All app-level SQL against this schema uses prepared statements with `?` placeholders -- +-- no string interpolation of values anywhere in App\Repository\Pdo\*. + +SET NAMES utf8mb4; + +-- --------------------------------------------------------------------------- +-- users: dashboard accounts (auth + RBAC + brute-force lockout). +-- No PdoUserRepository::create() exists in P1-B -- the first admin account is +-- provisioned out-of-band via the CLI snippet in cloud/docs/cloud-deploy.md. +-- --------------------------------------------------------------------------- +CREATE TABLE users ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(190) NOT NULL, + password_hash VARCHAR(255) NOT NULL, -- Argon2id, see App\Security\Password::hash() + role VARCHAR(32) NOT NULL, -- app-whitelisted: admin|operator|viewer (App\Auth\Rbac) + site_scope VARCHAR(190) NULL, + failed_attempts INT UNSIGNED NOT NULL DEFAULT 0, + locked_until BIGINT UNSIGNED NOT NULL DEFAULT 0, -- unix timestamp; 0 = not locked + created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()), + PRIMARY KEY (id), + UNIQUE KEY uq_users_username (username), + CONSTRAINT chk_users_role CHECK (role IN ('admin','operator','viewer')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- devices: one row per Pi/edge device; holds the per-device HMAC ingest secret. +-- No PdoDeviceRepository::create() exists -- rows are provisioned by an admin +-- (per-device secret issuance) before the device is allowed to sync/ingest. +-- --------------------------------------------------------------------------- +CREATE TABLE devices ( + device_id VARCHAR(64) NOT NULL, + name VARCHAR(190) NULL, + secret VARCHAR(255) NOT NULL, -- per-device HMAC key; rotate via admin CLI, never log + config_version INT UNSIGNED NOT NULL DEFAULT 1, -- authoritative version served by GET /api/config + last_fps DOUBLE NULL, + last_temp DOUBLE NULL, + last_throttle TINYINT(1) NULL, + reported_config_version INT UNSIGNED NULL, -- version the device last claimed via heartbeat + last_heartbeat_at BIGINT UNSIGNED NULL, + created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()), + PRIMARY KEY (device_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- cameras: per-device camera inventory (design doc §7.4). No dedicated +-- PdoCameraRepository exists in P1-B -- zones/events reference camera_id +-- directly as a free-form string. Kept for documentation/future per-camera +-- config and to avoid a breaking migration when that repository is added. +-- --------------------------------------------------------------------------- +CREATE TABLE cameras ( + camera_id VARCHAR(64) NOT NULL, + device_id VARCHAR(64) NOT NULL, + name VARCHAR(190) NULL, + role VARCHAR(32) NULL, -- app-whitelisted: fall_critical|zone|low + created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()), + PRIMARY KEY (camera_id), + KEY ix_cameras_device (device_id), + CONSTRAINT chk_cameras_role CHECK (role IS NULL OR role IN ('fall_critical','zone','low')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- zones: polygon regions per camera, edited via the zone-config dashboard. +-- No FK to cameras/devices: zones may legitimately be created before a +-- `cameras` row exists for a given camera_id (no camera-registration flow in +-- P1-B), so a FK here would spuriously reject valid app writes. +-- --------------------------------------------------------------------------- +CREATE TABLE zones ( + zone_id CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)), same convention as snapshot uuids + device_id VARCHAR(64) NOT NULL, + camera_id VARCHAR(64) NOT NULL, + level VARCHAR(16) NOT NULL, -- app-whitelisted: warning|restricted|pinch (ZoneConfigController::LEVELS) + polygon_json JSON NOT NULL, + updated_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (zone_id), + KEY ix_zones_camera (camera_id), + KEY ix_zones_device (device_id), + CONSTRAINT chk_zones_level CHECK (level IN ('warning','restricted','pinch')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- events: ingested detections. event_type/severity are already validated in +-- App\Ingest\EventValidator before insert(); the CHECK constraints below are +-- defense-in-depth only, not the primary validation layer. +-- --------------------------------------------------------------------------- +CREATE TABLE events ( + event_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + device_id VARCHAR(64) NOT NULL, + camera_id VARCHAR(64) NOT NULL, + event_type VARCHAR(32) NOT NULL, + severity VARCHAR(16) NOT NULL, + track_id INT NULL, + zone_id CHAR(32) NULL, + occurred_at DOUBLE NOT NULL, -- device-reported epoch seconds (float) + duration_s DOUBLE NOT NULL DEFAULT 0, + metrics_json JSON NULL, + acknowledged_by VARCHAR(190) NULL, + acknowledged_at BIGINT UNSIGNED NULL, + received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8) + PRIMARY KEY (event_id), + KEY ix_events_occurred_at (occurred_at), + KEY ix_events_severity_occurred_at (severity, occurred_at), + KEY ix_events_device (device_id), + CONSTRAINT chk_events_type CHECK (event_type IN ( + 'fall','no_motion','immobility','ghost','zone_warning', + 'zone_restricted','pinch','monitoring_degraded','device_fault' + )), + CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- event_snapshots: re-encoded JPEG references. Files live under storage/ +-- (webroot-adjacent, served only via the authenticated SnapshotProxy). FK to +-- events is safe because IngestController always inserts the parent event row +-- before calling SnapshotRepository::add() for its snapshots. +-- --------------------------------------------------------------------------- +CREATE TABLE event_snapshots ( + snapshot_id INT UNSIGNED NOT NULL AUTO_INCREMENT, + event_id INT UNSIGNED NOT NULL, + file_uuid CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)); on-disk filename = .jpg + content_hash CHAR(64) NOT NULL, -- sha256 of the re-encoded bytes + created_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (snapshot_id), + UNIQUE KEY uq_event_snapshots_uuid (file_uuid), + KEY ix_event_snapshots_event (event_id), + CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES events (event_id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- thresholds: per-device (optionally per-camera/zone) tunables synced via +-- GET /api/config. No PdoThresholdRepository write method exists in P1-B -- +-- rows are seeded/edited directly (phpMyAdmin) until a management UI lands. +-- --------------------------------------------------------------------------- +CREATE TABLE thresholds ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + device_id VARCHAR(64) NOT NULL, + camera_id VARCHAR(64) NULL, + zone_id CHAR(32) NULL, + `key` VARCHAR(64) NOT NULL, + `value` VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + KEY ix_thresholds_device (device_id), + UNIQUE KEY uq_thresholds_scope_key (device_id, camera_id, zone_id, `key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- nonces: replay-protection store for ingest/config/heartbeat HMAC requests. +-- `nonce` is the PRIMARY KEY (unique) so PdoNonceRepository::remember() is +-- safe against a duplicate-insert race: IngestController already calls +-- seen() before remember(), and a thrown duplicate-key error on a racing +-- remember() is an acceptable, expected outcome (closes the seen()/remember() +-- TOCTOU flagged in the Task 7 review -- the PK uniqueness is the real guard, +-- not the earlier seen() check). +-- --------------------------------------------------------------------------- +CREATE TABLE nonces ( + nonce VARCHAR(190) NOT NULL, + expires_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (nonce), + KEY ix_nonces_expires_at (expires_at) -- for a periodic `DELETE FROM nonces WHERE expires_at < ?` cleanup job +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- rate_limits: per-key sliding-window counters for ingest rate limiting. The +-- (rate_key, window_start) primary key backs an atomic +-- `INSERT ... ON DUPLICATE KEY UPDATE count = count + 1` upsert in +-- PdoRateLimiterRepository::hit() -- no read-modify-write race window. +-- --------------------------------------------------------------------------- +CREATE TABLE rate_limits ( + rate_key VARCHAR(190) NOT NULL, + window_start BIGINT UNSIGNED NOT NULL, -- floor(now / 60): the minute bucket + count INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (rate_key, window_start) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------------- +-- audit_log: append-only action log (acknowledge / zone save+delete / login). +-- Tamper-evident hash-chaining (design doc §9.2) is deferred to P3 per the +-- Task 16 brief self-review -- this table is the plain append-only record only. +-- --------------------------------------------------------------------------- +CREATE TABLE audit_log ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + actor VARCHAR(190) NOT NULL, + action VARCHAR(64) NOT NULL, + target VARCHAR(190) NOT NULL, + before_json JSON NOT NULL, + after_json JSON NOT NULL, + ts BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (id), + KEY ix_audit_log_ts (ts), + KEY ix_audit_log_actor (actor) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/cloud/src/Db/Database.php b/cloud/src/Db/Database.php new file mode 100644 index 0000000..489632f --- /dev/null +++ b/cloud/src/Db/Database.php @@ -0,0 +1,18 @@ +dbDsn(), $cfg->dbUser(), $cfg->dbPass(), [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } +} diff --git a/cloud/src/Repository/Pdo/PdoAuditRepository.php b/cloud/src/Repository/Pdo/PdoAuditRepository.php new file mode 100644 index 0000000..25bfb5e --- /dev/null +++ b/cloud/src/Repository/Pdo/PdoAuditRepository.php @@ -0,0 +1,27 @@ +pdo->prepare( + 'INSERT INTO audit_log (actor, action, target, before_json, after_json, ts) + VALUES (?, ?, ?, ?, ?, ?)' + ); + $st->execute([ + $actor, + $action, + $target, + json_encode($before, JSON_THROW_ON_ERROR), + json_encode($after, JSON_THROW_ON_ERROR), + time(), + ]); + } +} diff --git a/cloud/src/Repository/Pdo/PdoDeviceRepository.php b/cloud/src/Repository/Pdo/PdoDeviceRepository.php new file mode 100644 index 0000000..e43191d --- /dev/null +++ b/cloud/src/Repository/Pdo/PdoDeviceRepository.php @@ -0,0 +1,50 @@ +pdo->prepare('SELECT secret FROM devices WHERE device_id = ? LIMIT 1'); + $st->execute([$deviceId]); + $secret = $st->fetchColumn(); + return $secret === false ? null : (string)$secret; + } + + public function touchHeartbeat(string $deviceId, array $meta): void + { + // A plain UPDATE (not upsert) is safe here: the caller (HeartbeatController) + // only reaches this after secretFor() already succeeded for the same + // device_id, so the row is guaranteed to exist. Device rows are provisioned + // out-of-band by an admin (see cloud/docs/cloud-deploy.md), never created + // implicitly from an inbound heartbeat. + $st = $this->pdo->prepare( + 'UPDATE devices + SET last_fps = ?, last_temp = ?, last_throttle = ?, + reported_config_version = ?, last_heartbeat_at = ? + WHERE device_id = ?' + ); + $st->execute([ + (float)($meta['fps'] ?? 0.0), + (float)($meta['temp'] ?? 0.0), + !empty($meta['throttle']) ? 1 : 0, + (int)($meta['config_version'] ?? 0), + (int)($meta['at'] ?? time()), + $deviceId, + ]); + } + + public function configVersion(string $deviceId): int + { + $st = $this->pdo->prepare('SELECT config_version FROM devices WHERE device_id = ? LIMIT 1'); + $st->execute([$deviceId]); + $version = $st->fetchColumn(); + return $version === false ? 1 : (int)$version; + } +} diff --git a/cloud/src/Repository/Pdo/PdoEventRepository.php b/cloud/src/Repository/Pdo/PdoEventRepository.php new file mode 100644 index 0000000..431428c --- /dev/null +++ b/cloud/src/Repository/Pdo/PdoEventRepository.php @@ -0,0 +1,105 @@ +pdo->prepare( + 'INSERT INTO events + (device_id, camera_id, event_type, severity, track_id, zone_id, + occurred_at, duration_s, metrics_json, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $st->execute([ + (string)$event['device_id'], + (string)$event['camera_id'], + (string)$event['event_type'], + (string)$event['severity'], + $event['track_id'] ?? null, + $event['zone_id'] ?? null, + (float)$event['occurred_at'], + (float)($event['duration_s'] ?? 0.0), + json_encode($event['metrics'] ?? [], JSON_THROW_ON_ERROR), + time(), + ]); + return (int)$this->pdo->lastInsertId(); + } + + public function recent(int $limit, ?string $severity): array + { + if ($severity !== null) { + $st = $this->pdo->prepare( + 'SELECT ' . self::SELECT_COLUMNS . ' + FROM events WHERE severity = ? + ORDER BY occurred_at DESC LIMIT ?' + ); + $st->bindValue(1, $severity, PDO::PARAM_STR); + $st->bindValue(2, $limit, PDO::PARAM_INT); + } else { + $st = $this->pdo->prepare( + 'SELECT ' . self::SELECT_COLUMNS . ' + FROM events + ORDER BY occurred_at DESC LIMIT ?' + ); + $st->bindValue(1, $limit, PDO::PARAM_INT); + } + $st->execute(); + return array_map(self::mapRow(...), $st->fetchAll()); + } + + public function find(int $id): ?array + { + $st = $this->pdo->prepare('SELECT ' . self::SELECT_COLUMNS . ' FROM events WHERE event_id = ?'); + $st->execute([$id]); + $row = $st->fetch(); + return $row === false ? null : self::mapRow($row); + } + + public function acknowledge(int $id, string $user): bool + { + // Existence is checked separately (rather than trusting UPDATE's + // rowCount()) because MySQL's PDO driver, by default, reports + // "changed rows" rather than "matched rows": re-acknowledging an + // already-acknowledged event by the same user would otherwise report + // 0 affected rows and this method would wrongly return false. + $exists = $this->pdo->prepare('SELECT 1 FROM events WHERE event_id = ? LIMIT 1'); + $exists->execute([$id]); + if ($exists->fetchColumn() === false) { + return false; + } + $st = $this->pdo->prepare( + 'UPDATE events SET acknowledged_by = ?, acknowledged_at = ? WHERE event_id = ?' + ); + $st->execute([$user, time(), $id]); + return true; + } + + private static function mapRow(array $row): array + { + return [ + 'event_id' => (int)$row['event_id'], + 'device_id' => $row['device_id'], + 'camera_id' => $row['camera_id'], + 'event_type' => $row['event_type'], + 'severity' => $row['severity'], + 'track_id' => $row['track_id'] !== null ? (int)$row['track_id'] : null, + 'zone_id' => $row['zone_id'], + 'occurred_at' => (float)$row['occurred_at'], + 'duration_s' => (float)$row['duration_s'], + 'metrics' => json_decode((string)($row['metrics_json'] ?? '[]'), true) ?? [], + 'acknowledged_by' => $row['acknowledged_by'], + 'acknowledged_at' => $row['acknowledged_at'] !== null ? (int)$row['acknowledged_at'] : null, + ]; + } +} diff --git a/cloud/src/Repository/Pdo/PdoNonceRepository.php b/cloud/src/Repository/Pdo/PdoNonceRepository.php new file mode 100644 index 0000000..67ca49f --- /dev/null +++ b/cloud/src/Repository/Pdo/PdoNonceRepository.php @@ -0,0 +1,33 @@ +pdo->prepare('SELECT 1 FROM nonces WHERE nonce = ? LIMIT 1'); + $st->execute([$nonce]); + return (bool)$st->fetchColumn(); + } + + public function remember(string $nonce, int $expiresAt): void + { + $st = $this->pdo->prepare('INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)'); + $st->execute([$nonce, $expiresAt]); + } +} diff --git a/cloud/src/Repository/Pdo/PdoRateLimiterRepository.php b/cloud/src/Repository/Pdo/PdoRateLimiterRepository.php new file mode 100644 index 0000000..b0bba5f --- /dev/null +++ b/cloud/src/Repository/Pdo/PdoRateLimiterRepository.php @@ -0,0 +1,32 @@ +pdo->prepare( + 'INSERT INTO rate_limits (rate_key, window_start, count) VALUES (?, ?, 1) + ON DUPLICATE KEY UPDATE count = count + 1' + ); + $upsert->execute([$key, $windowStart]); + + $select = $this->pdo->prepare( + 'SELECT count FROM rate_limits WHERE rate_key = ? AND window_start = ?' + ); + $select->execute([$key, $windowStart]); + return (int)$select->fetchColumn(); + } +} diff --git a/cloud/src/Repository/Pdo/PdoSnapshotRepository.php b/cloud/src/Repository/Pdo/PdoSnapshotRepository.php new file mode 100644 index 0000000..ea04bf5 --- /dev/null +++ b/cloud/src/Repository/Pdo/PdoSnapshotRepository.php @@ -0,0 +1,50 @@ +pdo->prepare( + 'INSERT INTO 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 event_snapshots + WHERE event_id = ? ORDER BY snapshot_id ASC' + ); + $st->execute([$eventId]); + return $st->fetchAll(); + } + + 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 event_snapshots WHERE file_uuid = ? LIMIT 1'); + $st->execute([$uuid]); + if ($st->fetchColumn() === false) { + return null; + } + + return rtrim($this->snapshotDir, '/\\') . '/' . $uuid . '.jpg'; + } +} diff --git a/cloud/src/Repository/Pdo/PdoThresholdRepository.php b/cloud/src/Repository/Pdo/PdoThresholdRepository.php new file mode 100644 index 0000000..37e041a --- /dev/null +++ b/cloud/src/Repository/Pdo/PdoThresholdRepository.php @@ -0,0 +1,23 @@ +pdo->prepare('SELECT `key`, `value` FROM thresholds WHERE device_id = ?'); + $st->execute([$deviceId]); + + $out = []; + foreach ($st->fetchAll() as $row) { + $out[$row['key']] = $row['value']; + } + return $out; + } +} diff --git a/cloud/src/Repository/Pdo/PdoUserRepository.php b/cloud/src/Repository/Pdo/PdoUserRepository.php new file mode 100644 index 0000000..dd0dd57 --- /dev/null +++ b/cloud/src/Repository/Pdo/PdoUserRepository.php @@ -0,0 +1,49 @@ +pdo->prepare( + 'SELECT id, username, password_hash, role, site_scope, failed_attempts, locked_until + FROM users WHERE username = ? LIMIT 1' + ); + $st->execute([$username]); + $row = $st->fetch(); + if ($row === false) { + return null; + } + return [ + 'id' => (int)$row['id'], + 'username' => $row['username'], + 'password_hash' => $row['password_hash'], + 'role' => $row['role'], + 'site_scope' => $row['site_scope'], + 'failed_attempts' => (int)$row['failed_attempts'], + 'locked_until' => (int)$row['locked_until'], + ]; + } + + public function recordFailure(int $userId, int $lockUntil): void + { + // Increment in SQL (not read-then-write in PHP) so concurrent failed + // logins for the same account can't clobber each other's count. + $st = $this->pdo->prepare( + 'UPDATE users SET failed_attempts = failed_attempts + 1, locked_until = ? WHERE id = ?' + ); + $st->execute([$lockUntil, $userId]); + } + + public function clearFailures(int $userId): void + { + $st = $this->pdo->prepare('UPDATE users SET failed_attempts = 0, locked_until = 0 WHERE id = ?'); + $st->execute([$userId]); + } +} diff --git a/cloud/src/Repository/Pdo/PdoZoneRepository.php b/cloud/src/Repository/Pdo/PdoZoneRepository.php new file mode 100644 index 0000000..fd34878 --- /dev/null +++ b/cloud/src/Repository/Pdo/PdoZoneRepository.php @@ -0,0 +1,91 @@ +pdo->prepare( + 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM zones WHERE device_id = ?' + ); + $st->execute([$deviceId]); + return $st->fetchAll(); + } + + public function forCamera(string $cameraId): array + { + $st = $this->pdo->prepare( + 'SELECT zone_id, device_id, camera_id, level, polygon_json FROM zones WHERE camera_id = ?' + ); + $st->execute([$cameraId]); + return $st->fetchAll(); + } + + public function save(array $zone): string + { + $zoneId = $zone['zone_id'] ?? null; + $deviceId = (string)($zone['device_id'] ?? ''); + $cameraId = (string)($zone['camera_id'] ?? ''); + $level = (string)($zone['level'] ?? ''); + $polygonJson = (string)($zone['polygon_json'] ?? '[]'); + + if (is_string($zoneId) && $zoneId !== '') { + return $this->updateOwned($zoneId, $deviceId, $cameraId, $level, $polygonJson); + } + + $newId = bin2hex(random_bytes(16)); + $st = $this->pdo->prepare( + 'INSERT INTO zones (zone_id, device_id, camera_id, level, polygon_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?)' + ); + $st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, time()]); + return $newId; + } + + public function delete(string $zoneId): bool + { + $st = $this->pdo->prepare('DELETE FROM zones WHERE zone_id = ?'); + $st->execute([$zoneId]); + return $st->rowCount() > 0; + } + + /** + * Update path for save(): scoped by (zone_id, device_id, camera_id) so a + * caller cannot overwrite an unrelated zone by supplying its id with a + * different device_id/camera_id (closes the cross-tenant overwrite risk + * flagged in the Task 13 review). Ownership is verified with a SELECT + * before the UPDATE (rather than trusting UPDATE's rowCount()) because + * MySQL reports "changed rows", not "matched rows" -- a no-op update + * (identical level/polygon resubmitted) would otherwise report 0 affected + * rows and be indistinguishable from an ownership mismatch. + */ + private function updateOwned( + string $zoneId, + string $deviceId, + string $cameraId, + string $level, + string $polygonJson, + ): string { + $owns = $this->pdo->prepare( + 'SELECT 1 FROM zones WHERE zone_id = ? AND device_id = ? AND camera_id = ? LIMIT 1' + ); + $owns->execute([$zoneId, $deviceId, $cameraId]); + if ($owns->fetchColumn() === false) { + throw new RuntimeException('zone not found or not owned by the given device/camera'); + } + + $st = $this->pdo->prepare( + 'UPDATE zones SET level = ?, polygon_json = ?, updated_at = ? + WHERE zone_id = ? AND device_id = ? AND camera_id = ?' + ); + $st->execute([$level, $polygonJson, time(), $zoneId, $deviceId, $cameraId]); + return $zoneId; + } +} diff --git a/cloud/tests/PdoIntegrationTest.php b/cloud/tests/PdoIntegrationTest.php new file mode 100644 index 0000000..fa2fa80 --- /dev/null +++ b/cloud/tests/PdoIntegrationTest.php @@ -0,0 +1,169 @@ +markTestSkipped('pdo_sqlite not available on this host'); + } + + $this->pdo = new PDO('sqlite::memory:', null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $this->pdo->exec(self::sqliteSchema()); + } + + public function testNonceRoundTrip(): void + { + $repo = new PdoNonceRepository($this->pdo); + $this->assertFalse($repo->seen('n1')); + $repo->remember('n1', 9999999999); + $this->assertTrue($repo->seen('n1')); + } + + public function testEventInsertFindAcknowledge(): void + { + $repo = new PdoEventRepository($this->pdo); + $id = $repo->insert([ + 'device_id' => 'dev-1', + 'camera_id' => 'cam-1', + 'event_type' => 'fall', + 'severity' => 'critical', + 'track_id' => 7, + 'zone_id' => null, + 'occurred_at' => 1700000000.5, + 'duration_s' => 2.5, + 'metrics' => ['confidence' => 0.9], + ]); + $this->assertGreaterThan(0, $id); + + $found = $repo->find($id); + $this->assertNotNull($found); + $this->assertSame('dev-1', $found['device_id']); + $this->assertSame('critical', $found['severity']); + $this->assertSame(['confidence' => 0.9], $found['metrics']); + $this->assertNull($found['acknowledged_by']); + + $recent = $repo->recent(10, 'critical'); + $this->assertCount(1, $recent); + $this->assertSame([], $repo->recent(10, 'info')); + + $this->assertTrue($repo->acknowledge($id, 'alice')); + $this->assertSame('alice', $repo->find($id)['acknowledged_by']); + $this->assertFalse($repo->acknowledge(999999, 'bob')); + } + + public function testZoneSaveIsOwnershipScoped(): void + { + $repo = new PdoZoneRepository($this->pdo); + + $zoneId = $repo->save([ + 'zone_id' => null, + 'device_id' => 'dev-1', + 'camera_id' => 'cam-1', + 'level' => 'warning', + 'polygon_json' => '[[0,0],[1,0],[1,1]]', + ]); + $this->assertNotSame('', $zoneId); + $this->assertCount(1, $repo->forCamera('cam-1')); + + // Legitimate update by the owning device/camera succeeds. + $again = $repo->save([ + 'zone_id' => $zoneId, + 'device_id' => 'dev-1', + 'camera_id' => 'cam-1', + 'level' => 'restricted', + 'polygon_json' => '[[0,0],[2,0],[2,2]]', + ]); + $this->assertSame($zoneId, $again); + + // Supplying the same zone_id under a different device/camera must not + // silently overwrite it -- this is the cross-tenant guard from the + // Task 13 review. + $this->expectException(RuntimeException::class); + $repo->save([ + 'zone_id' => $zoneId, + 'device_id' => 'dev-2', + 'camera_id' => 'cam-2', + 'level' => 'pinch', + 'polygon_json' => '[[9,9],[9,8],[8,8]]', + ]); + } + + public function testZoneDelete(): void + { + $repo = new PdoZoneRepository($this->pdo); + $zoneId = $repo->save([ + 'zone_id' => null, + 'device_id' => 'dev-1', + 'camera_id' => 'cam-1', + 'level' => 'warning', + 'polygon_json' => '[[0,0],[1,0],[1,1]]', + ]); + $this->assertTrue($repo->delete($zoneId)); + $this->assertFalse($repo->delete($zoneId)); + } + + /** + * Minimal sqlite-flavored translation of the nonces/events/zones tables + * in ../schema.sql (see that file's "sqlite compatibility notes" header + * for the general MySQL -> sqlite mapping rules applied here). + */ + private static function sqliteSchema(): string + { + return <<<'SQL' + CREATE TABLE nonces ( + nonce TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL + ); + + CREATE TABLE events ( + event_id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + camera_id TEXT NOT NULL, + event_type TEXT NOT NULL, + severity TEXT NOT NULL, + track_id INTEGER, + zone_id TEXT, + occurred_at REAL NOT NULL, + duration_s REAL NOT NULL DEFAULT 0, + metrics_json TEXT, + acknowledged_by TEXT, + acknowledged_at INTEGER, + received_at INTEGER NOT NULL + ); + + CREATE TABLE zones ( + zone_id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + camera_id TEXT NOT NULL, + level TEXT NOT NULL, + polygon_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + SQL; + } +}