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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1
This commit is contained in:
parent
81eae5d045
commit
9aa7f44ea4
13 changed files with 1105 additions and 0 deletions
239
cloud/docs/cloud-deploy.md
Normal file
239
cloud/docs/cloud-deploy.md
Normal file
|
|
@ -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/<account>/ <- 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
|
||||
<?php var_dump(defined('PASSWORD_ARGON2ID'), in_array('argon2i', password_algos()));
|
||||
```
|
||||
**This is the one that most commonly bites on shared hosting** — some PHP builds
|
||||
ship without libargon2, in which case `PASSWORD_ARGON2ID` is undefined and both
|
||||
`App\Security\Password::hash()` (account creation) and
|
||||
`App\Security\Password::verify()` (login, called on *every* login attempt including
|
||||
unknown-username ones, per `AuthController::DUMMY_HASH`) fatal immediately — **the
|
||||
dashboard login is completely broken** until this is fixed. If your plan lacks it,
|
||||
escalate with Cafe24 support to switch to a PHP build with Argon2, or migrate the
|
||||
hashing scheme (out of scope for this doc — do not silently downgrade to a weaker
|
||||
algorithm).
|
||||
- [ ] **HTTPS is available** for the domain/subdomain you're deploying to (Cafe24 free or
|
||||
custom TLS certificate) — see §7.
|
||||
|
||||
## 3. `config.php`
|
||||
|
||||
Copy the example and fill in real values — **never commit `config.php`** (already covered
|
||||
by `cloud/.gitignore`):
|
||||
|
||||
```bash
|
||||
cp config.example.php config.php
|
||||
```
|
||||
|
||||
Edit `config.php` (outside the web root, per §1):
|
||||
|
||||
```php
|
||||
<?php
|
||||
return [
|
||||
'db_dsn' => 'mysql:host=localhost;dbname=<your_db_name>;charset=utf8mb4',
|
||||
'db_user' => '<cafe24_mysql_user>',
|
||||
'db_pass' => '<cafe24_mysql_password>',
|
||||
'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', '<paste the generated secret here>', 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', '<paste the argon2id hash here>', '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/<account>/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://<domain>/../config.php`
|
||||
and `curl -I https://<domain>/storage/snapshots/` should not return the file/listing.
|
||||
219
cloud/schema.sql
Normal file
219
cloud/schema.sql
Normal file
|
|
@ -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 = <uuid>.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;
|
||||
18
cloud/src/Db/Database.php
Normal file
18
cloud/src/Db/Database.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Db;
|
||||
|
||||
use App\Config;
|
||||
use PDO;
|
||||
|
||||
final class Database
|
||||
{
|
||||
public static function connect(Config $cfg): PDO
|
||||
{
|
||||
return new PDO($cfg->dbDsn(), $cfg->dbUser(), $cfg->dbPass(), [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
27
cloud/src/Repository/Pdo/PdoAuditRepository.php
Normal file
27
cloud/src/Repository/Pdo/PdoAuditRepository.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Repository\Pdo;
|
||||
|
||||
use App\Repository\AuditRepository;
|
||||
use PDO;
|
||||
|
||||
final class PdoAuditRepository implements AuditRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo) {}
|
||||
|
||||
public function append(string $actor, string $action, string $target, array $before, array $after): void
|
||||
{
|
||||
$st = $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
50
cloud/src/Repository/Pdo/PdoDeviceRepository.php
Normal file
50
cloud/src/Repository/Pdo/PdoDeviceRepository.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Repository\Pdo;
|
||||
|
||||
use App\Repository\DeviceRepository;
|
||||
use PDO;
|
||||
|
||||
final class PdoDeviceRepository implements DeviceRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo) {}
|
||||
|
||||
public function secretFor(string $deviceId): ?string
|
||||
{
|
||||
$st = $this->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;
|
||||
}
|
||||
}
|
||||
105
cloud/src/Repository/Pdo/PdoEventRepository.php
Normal file
105
cloud/src/Repository/Pdo/PdoEventRepository.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Repository\Pdo;
|
||||
|
||||
use App\Repository\EventRepository;
|
||||
use PDO;
|
||||
|
||||
final class PdoEventRepository implements EventRepository
|
||||
{
|
||||
private const SELECT_COLUMNS =
|
||||
'event_id, device_id, camera_id, event_type, severity, track_id, zone_id,
|
||||
occurred_at, duration_s, metrics_json, acknowledged_by, acknowledged_at';
|
||||
|
||||
public function __construct(private PDO $pdo) {}
|
||||
|
||||
public function insert(array $event): int
|
||||
{
|
||||
$st = $this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
33
cloud/src/Repository/Pdo/PdoNonceRepository.php
Normal file
33
cloud/src/Repository/Pdo/PdoNonceRepository.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Repository\Pdo;
|
||||
|
||||
use App\Repository\NonceRepository;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* `nonces.nonce` is the PRIMARY KEY (see schema.sql). IngestController already
|
||||
* calls seen() before remember(), so under normal operation remember() never
|
||||
* collides. Under a replay race (two requests with the same nonce arriving
|
||||
* concurrently), the PK uniqueness constraint is the real guard: the losing
|
||||
* remember() throws a PDOException on the duplicate key instead of silently
|
||||
* overwriting -- closing the seen()/remember() TOCTOU flagged in the Task 7
|
||||
* review. We deliberately do not catch/swallow that exception here.
|
||||
*/
|
||||
final class PdoNonceRepository implements NonceRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo) {}
|
||||
|
||||
public function seen(string $nonce): bool
|
||||
{
|
||||
$st = $this->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]);
|
||||
}
|
||||
}
|
||||
32
cloud/src/Repository/Pdo/PdoRateLimiterRepository.php
Normal file
32
cloud/src/Repository/Pdo/PdoRateLimiterRepository.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Repository\Pdo;
|
||||
|
||||
use App\Repository\RateLimiterRepository;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Atomic upsert via MySQL's INSERT ... ON DUPLICATE KEY UPDATE against the
|
||||
* unique (rate_key, window_start) primary key (see schema.sql) -- no
|
||||
* read-modify-write race between reading the current count and writing the
|
||||
* incremented value back.
|
||||
*/
|
||||
final class PdoRateLimiterRepository implements RateLimiterRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo) {}
|
||||
|
||||
public function hit(string $key, int $windowStart): int
|
||||
{
|
||||
$upsert = $this->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();
|
||||
}
|
||||
}
|
||||
50
cloud/src/Repository/Pdo/PdoSnapshotRepository.php
Normal file
50
cloud/src/Repository/Pdo/PdoSnapshotRepository.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Repository\Pdo;
|
||||
|
||||
use App\Repository\SnapshotRepository;
|
||||
use PDO;
|
||||
|
||||
final class PdoSnapshotRepository implements SnapshotRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo, private string $snapshotDir) {}
|
||||
|
||||
public function add(int $eventId, string $fileUuid, string $contentHash): void
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'INSERT INTO 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';
|
||||
}
|
||||
}
|
||||
23
cloud/src/Repository/Pdo/PdoThresholdRepository.php
Normal file
23
cloud/src/Repository/Pdo/PdoThresholdRepository.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Repository\Pdo;
|
||||
|
||||
use App\Repository\ThresholdRepository;
|
||||
use PDO;
|
||||
|
||||
final class PdoThresholdRepository implements ThresholdRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo) {}
|
||||
|
||||
public function forDevice(string $deviceId): array
|
||||
{
|
||||
$st = $this->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;
|
||||
}
|
||||
}
|
||||
49
cloud/src/Repository/Pdo/PdoUserRepository.php
Normal file
49
cloud/src/Repository/Pdo/PdoUserRepository.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Repository\Pdo;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use PDO;
|
||||
|
||||
final class PdoUserRepository implements UserRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo) {}
|
||||
|
||||
public function byUsername(string $username): ?array
|
||||
{
|
||||
$st = $this->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]);
|
||||
}
|
||||
}
|
||||
91
cloud/src/Repository/Pdo/PdoZoneRepository.php
Normal file
91
cloud/src/Repository/Pdo/PdoZoneRepository.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Repository\Pdo;
|
||||
|
||||
use App\Repository\ZoneRepository;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class PdoZoneRepository implements ZoneRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo) {}
|
||||
|
||||
public function forDevice(string $deviceId): array
|
||||
{
|
||||
$st = $this->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;
|
||||
}
|
||||
}
|
||||
169
cloud/tests/PdoIntegrationTest.php
Normal file
169
cloud/tests/PdoIntegrationTest.php
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Repository\Pdo\{PdoNonceRepository, PdoEventRepository, PdoZoneRepository};
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Gated DB-integration test: exercises the PDO repositories against a real
|
||||
* sqlite :memory: database instead of the in-memory test doubles used
|
||||
* elsewhere in the suite. This host has no pdo_sqlite driver loaded
|
||||
* (PDO::getAvailableDrivers() does not include 'sqlite'), so setUp() skips
|
||||
* the whole class -- the suite must stay green with this test reported as
|
||||
* SKIPPED, not failed. On a host with pdo_sqlite available (or in CI), this
|
||||
* test runs for real and round-trips Nonce, Event, and Zone -- the three
|
||||
* repositories whose SQL is portable between MySQL and sqlite (no
|
||||
* MySQL-only `ON DUPLICATE KEY UPDATE`, which PdoRateLimiterRepository uses
|
||||
* and is therefore intentionally not covered here).
|
||||
*/
|
||||
final class PdoIntegrationTest extends TestCase
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) {
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue