# 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. **Minimum version — MySQL 8.0.13+ (or MariaDB 10.2+).** Several tables use an expression default `... DEFAULT (UNIX_TIMESTAMP())` (e.g. `move_nonces`, `move_audit_log`), which is a hard syntax requirement: MySQL added function/expression defaults in **8.0.13**, and on an older server the very first such `CREATE TABLE` fails the import with `ERROR 1064` — a day-1 blocker, not a silent degradation. **Check the version in the Cafe24 hosting control panel (or `SELECT VERSION();`) before importing.** If the account only offers MySQL 5.7, either request an 8.0 database or replace each `DEFAULT (UNIX_TIMESTAMP())` with an application-supplied timestamp (the code already passes one on every insert). Separately, if your MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL (on `role`, `event_type`, `severity`, `level`) are 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`). ## 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.