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
239 lines
12 KiB
Markdown
239 lines
12 KiB
Markdown
# 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.
|