Three intertwined bodies of work developed and tested together this session. Full test suite green: python (edge) + phpunit (cloud). ## Edge/Pi install path - setup_online.sh: apt-lock retry (apt-daily contention), Type=exec unit + StartLimitIntervalSec in [Unit], journalctl-on-failure, crash-loop detection. - onnxruntime is now best-effort (no sdist / no 32-bit-ARM wheel); [6/8] probes the real backend and hard-fails if none works. - Hailo HEF auto-fetch: edge/tools/fetch_hailo_hef.py (pinned sha256, refuses to guess 8 vs 8L, HAILO_ARCH override for serial-less boards); hailo_pose.py run() timeout fix. - install_offline.sh: mirrors the backend gate; onnxruntime optional. ## Cafe24 cloud package (cloud/deploy/) - build_cafe24_package.sh emits move_detect_app/ (private, one folder) + www/move_detect/ (docroot subdir); no-dev classmap autoloader; preflight.php; hardened .htaccess. - Subdirectory mount support: Request::baseFrom/pathFor strip the mount prefix; index.php walks up to find the app root; views/JS prefix every URL with APP_BASE; session cookie scoped to the mount. ## Delivery audit — silent-failure & correctness (docs/delivery-audit.md) All 4 blockers + all Phase-1 silent-failure + Phase-2 HIGH correctness: - Edge cloud clients send a custom User-Agent (cafe24 WAF blocks python-requests) and allow_redirects=False. - Daemon fails loud on blank CAFE24_URL/DEVICE_SECRET; run_loop logs swallowed exceptions; heartbeat carries real fps + per-camera health; UvcSource reopens a dead camera; offline queue is size-capped and drops poison rows; TARGET_FPS/QUEUE_DB validated. - Zone coordinates normalized 0..1 on both sides (were canvas-px vs camera-px — every zone misplaced); pinch zones emit PINCH/HIGH (were downgraded to WARNING). - Fall detection uses bbox cues when keypoints are low-confidence (occluded fallen person); ghost incident not cancelled by a bystander; displacement normalized per-second (FPS-invariant). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
14 KiB
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, ...)
├── 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.
cloud/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.
cloud/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:
cd cloud && 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 requires
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.
cloud/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
requires 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
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_mysqlextension is enabled (PDO::getAvailableDrivers()must includemysql).gdextension is enabled —App\Ingest\ImageProcessor::reencode()callsimagecreatefromstring()/imagejpeg()to re-encode uploaded snapshots; withoutgd, every event with a snapshot silently drops that snapshot (reencode()returns['ok' => false]and the caller justcontinues).opensslextension is enabled — required forrandom_bytes()'s CSPRNG backing and TLS to the DB/clients on most builds; effectively always present, but confirm.PASSWORD_ARGON2IDsupport (theargon2build flag on PHP'spassword_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 casePASSWORD_ARGON2IDis undefined and bothApp\Security\Password::hash()(account creation) andApp\Security\Password::verify()(login, called on every login attempt including unknown-username ones, perAuthController::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):
cp config.example.php config.php
Edit config.php (outside the web root, per §1):
<?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).
- Open phpMyAdmin from the Cafe24 control panel, select the database created for this app.
- Import tab → choose file → upload
schema.sql→ Go. - Confirm all 10 tables were created (phpMyAdmin's table list on the left, or
SHOW TABLES;in the SQL tab). - Delete
schema.sqlfrom 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):
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):
php -r "echo bin2hex(random_bytes(32)), \"\n\";"
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:
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.)
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/.htaccessabove the existing rewrite-to-index rule). - Add HSTS via
public/.htaccess:apache Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"(requiresmod_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 = 1at the PHP level too if your Cafe24 plan lets you setphp.ini/.user.inioverrides, as defense-in-depth alongside the explicitcookie_secureoption 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):
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 /loginrenders over HTTPS.- Log in with the admin account from §7; dashboard (
/) loads and polls/api/events. POST /api/heartbeatfrom a real (or curl-simulated, correctly-signed) device using the secret from §6 returns200 {"ok":true}.- A snapshot uploaded via
/api/eventsis retrievable at/snap/{uuid}only while logged in, and404s for a made-up UUID. storage/snapshots/,src/,config.php, andschema.sql(if not yet deleted per §4) are not reachable over HTTP — e.g.curl -I https://<domain>/../config.phpandcurl -I https://<domain>/storage/snapshots/should not return the file/listing.