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:
유창욱 2026-07-12 01:58:05 +09:00
parent 81eae5d045
commit 9aa7f44ea4
13 changed files with 1105 additions and 0 deletions

239
cloud/docs/cloud-deploy.md Normal file
View 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
View 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
View 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,
]);
}
}

View 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(),
]);
}
}

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

View 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,
];
}
}

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

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

View 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';
}
}

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

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

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

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