move_detect/cloud/docs/cloud-deploy.md
유창욱 9aa7f44ea4 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
2026-07-12 01:58:05 +09:00

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.