# Cafe24 Server Deploy Guide (P1-B) This document covers deploying the `server/` 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 `server/public/` may be reachable from the web. Everything else must sit **outside** the web root (one level up, or wherever your Cafe24 plan's "web root" / "html" folder is), or be blocked by hosting-level access rules if your plan cannot place files outside the web root at all. ``` /home// <- NOT web-reachable ├── src/ <- App\ classes (App\Db, App\Repository\Pdo, ...) ├── vendor/ <- Composer's generated autoloader ONLY (see below) ├── 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. On Cafe24 it is named `www`; when that `www` ├── index.php already serves another site the app is mounted in a ├── .htaccess subdirectory (`www/move_detect/`) and the entries above └── assets/ sit at the FTP root — see below. ``` `server/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from itself until it sees both `config.php` and `vendor/autoload.php` (max 4 levels). It does not hardcode `/..`, so the same tree works whether the app owns the web root or is mounted in a subdirectory of one — in the latter case the app root is two levels up, not one, because nothing secret may sit under a docroot the other site also serves. The URL prefix comes from `SCRIPT_NAME` (`App\Http\Request::baseFrom()`), which the web server sets — never from client input. `Request::pathFor()` strips it before routing, and views/JS prefix every link with it (`view_url()` / `window.APP_BASE`). Without that, routes declared as `/login` would 404 under `https://host/move_detect/login` and every asset would resolve against the co-hosted site. `server/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=` (empty) to mount the app directly in `www/`. **`vendor/` must ship — but only the autoloader.** `public/index.php:3` is an unconditional `require __DIR__ . '/../vendor/autoload.php'`, so a transfer without `vendor/` fatals on every request, including §10's own smoke check. There are no runtime Composer *packages* (`composer.lock`'s `packages` is empty), but the app does need Composer's generated autoloader to map `App\` → `src/`. Generate the no-dev one on the build machine: ```bash cd server && composer dump-autoload --no-dev --classmap-authoritative --no-interaction ``` That emits ~8 files (~30 KB): `vendor/autoload.php` plus `vendor/composer/*.php`. Copy exactly those. Do **not** upload the working tree's `vendor/` as-is — it carries PHPUnit and a dev `autoload_files.php` that eagerly `require`s `tests/Support/InMemoryRepositories.php` on every request, a file this deployment deliberately excludes. `--classmap-authoritative` is not optional: `src/Auth/Session.php` declares three symbols (`SessionStore`, `ArraySessionStore`, `PhpSessionStore`) under a filename matching none of them — which is why `composer.json` carries an explicit `classmap` entry for it. Plain PSR-4 would look for `src/Auth/PhpSessionStore.php`, miss, and fatal at `index.php:19`. `server/deploy/build_cafe24_package.sh` does all of this, verifies it, and lays out the tree below automatically — prefer it over assembling the transfer by hand. If your Cafe24 plan cannot place folders outside the web root, **stop — this layout is unsupported, and the obvious workaround does not work.** `public/index.php` resolves `../vendor/autoload.php` (line 3), `../config.php` (line 17), and `../src/View/` (line 43) relative to itself, so moving those *into* `public/` puts them exactly where those three `require`s do not look: every request fatals. Making it work needs source changes to `index.php`'s path resolution plus deny rules for `src/`, `vendor/`, `storage/` **and** `config.php` — not deny rules alone. Raise it before deploying rather than improvising: `config.php` holds the DB password, and a half-applied version of this is how it ends up web-readable. ## 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 `server/composer.json`'s `"php": ">=8.2"` (the app is written and tested against PHP 8.5; anything ≥ 8.2 should run it, but confirm the exact version Cafe24 provisions for your plan). - [ ] **`pdo_mysql`** extension is enabled (`PDO::getAvailableDrivers()` must include `mysql`). - [ ] **`gd`** extension is enabled — `App\Ingest\ImageProcessor::reencode()` calls `imagecreatefromstring()` / `imagejpeg()` to re-encode uploaded snapshots; without `gd`, every event with a snapshot silently drops that snapshot (`reencode()` returns `['ok' => false]` and the caller just `continue`s). - [ ] **`openssl`** extension is enabled — required for `random_bytes()`'s CSPRNG backing and TLS to the DB/clients on most builds; effectively always present, but confirm. - [ ] **`PASSWORD_ARGON2ID` support** (the `argon2` build flag on PHP's `password_hash()`). Run: ```php 'mysql:host=localhost;dbname=;charset=utf8mb4', 'db_user' => '', 'db_pass' => '', 'snapshot_dir' => __DIR__ . '/storage/snapshots', 'nonce_ttl' => 86400, 'timestamp_window' => 300, 'rate_limit_per_min' => 60, ]; ``` Get the DB host/user/password/database name from the Cafe24 hosting control panel's MySQL section. Do not reuse the DB password anywhere else, and do not paste it into chat, tickets, or version control. ## 4. Import the schema `server/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 move_devices (device_id, name, secret, config_version) VALUES ('pi-01', 'Line 3 — Fall Cam', '', 1); ``` Deliver the secret to the device provisioning process out-of-band (not email/chat) and configure it as the device's `HMAC` signing key for `X-Signature` on `/api/events`, `/api/heartbeat`, and `/api/config`. Rotate a compromised secret by `UPDATE move_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 move_users (username, password_hash, role, site_scope, failed_attempts, locked_until) VALUES ('admin', '', 'admin', NULL, 0, 0); ``` Log in at `/login`, then change the password by re-running the CLI snippet with a new passphrase and updating the row (there's no self-service "change password" flow in P1-B). ## 8. HTTPS / HSTS The dashboard sets `cookie_secure => true` on its session cookie (`App\Auth\Session::PhpSessionStore`) — **the session cookie will not be sent by the browser over plain HTTP at all**, so the dashboard is non-functional without HTTPS; this isn't optional hardening, it's a hard requirement for login to work. - [ ] Enable Cafe24's free TLS certificate (or upload your own) for the domain. - [ ] Force HTTP → HTTPS redirect (Cafe24 control panel setting, or add a rewrite rule in `public/.htaccess` above the existing rewrite-to-index rule). - [ ] Add HSTS via `public/.htaccess`: ```apache Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" ``` (requires `mod_headers`; most Cafe24 Apache plans have it enabled by default — verify the header actually appears on a response before relying on it). - [ ] Set `session.cookie_secure = 1` at the PHP level too if your Cafe24 plan lets you set `php.ini`/`.user.ini` overrides, as defense-in-depth alongside the explicit `cookie_secure` option already passed in code. ## 9. Error display (do not leak stack traces) Set in `php.ini` or a `.user.ini` in `public/` (whichever your Cafe24 plan supports): ```ini display_errors = Off log_errors = On error_log = /home//logs/php_errors.log ; outside the web root ``` Several `Pdo*Repository` methods intentionally `throw` on invariant violations that should never happen in normal operation (e.g. `PdoZoneRepository::save()` throws `RuntimeException` if a caller supplies a `zone_id` that doesn't belong to the given `device_id`/`camera_id`, to prevent one device's zones being silently overwritten by another's). With `display_errors` off, an uncaught exception like that surfaces to the client as a bare 500 with no body/trace, which is the intended behavior — turning `display_errors` on in production would leak file paths and internal structure instead. ## 10. Post-deploy smoke check - [ ] `GET /login` renders over HTTPS. - [ ] Log in with the admin account from §7; dashboard (`/`) loads and polls `/api/events`. - [ ] `POST /api/heartbeat` from a real (or curl-simulated, correctly-signed) device using the secret from §6 returns `200 {"ok":true}`. - [ ] A snapshot uploaded via `/api/events` is retrievable at `/snap/{uuid}` only while logged in, and `404`s for a made-up UUID. - [ ] `storage/snapshots/`, `src/`, `config.php`, and `schema.sql` (if not yet deleted per §4) are **not** reachable over HTTP — e.g. `curl -I https:///../config.php` and `curl -I https:///storage/snapshots/` should not return the file/listing.