move_detect/server/docs/server-deploy.md
유창욱 6b1f4c483f fix(security,delivery): scope improvement actions by site; correct upgrade procedure
프로젝트 8개 차원 감사(종합 82/100)에서 확정된 상위 3건 수정.

1. 개선 조치(move_actions)가 유일하게 site 스코프 규율에서 이탈 — 스코프 운영자가
   타 현장의 조치 기록(카메라 ID·메모·작성자)을 목록에서 읽고, 타 현장 카메라에
   조치를 기록해 그 현장 리포트의 전후 효과 지표를 오염시킬 수 있었다.
   - move_actions.site 컬럼 + (site, taken_at) 인덱스, 기존 행 백필 마이그레이션
   - 카메라 소유 현장은 이벤트에서 해석 (move_cameras는 등록 플로우가 없어 항상 비어 있음)
   - 오타 카메라는 400: 전후 효과가 영원히 0/0이 되는 조치를 애초에 못 만들게 한다
   - 타 현장 카메라는 404: 존재 여부를 숨기는 기존 규약 유지

2. README-UPLOAD 업그레이드 절차가 마이그레이션 7종 중 1종만 지시했다. move_ 접두
   리네임을 건너뛰면 이후 전부 실패하고 전 요청이 500이 된다 — 순서·적용조건·재실행
   비안전성을 표로 명시. 문서 4곳의 테이블 수(10→11)·비프리픽스 표기도 정정.

3. 두 납품 번들 재생성: 8/6 이후 4개 기능 커밋이 반영되지 않아 Report/Export/Actions
   컨트롤러와 8/7 마이그레이션이 빠진 채였다. 엣지 소스 패리티도 회복.

검증: pytest 235 passed / PHPUnit 147 passed (PDO 4 skip), 번들 산출물 내부 검증
(28 클래스 해석·전 파일 php -l·SHA256SUMS 55행·휠 11개·HEF 다이제스트 일치).
2026-08-07 18:06:32 +09:00

15 KiB

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/<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.

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:

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 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.

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 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 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 continues).
  • 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 server/.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

server/schema.sql contains the full MySQL 8.0+ DDL. All tables carry the move_ prefix (move_users, move_devices, move_cameras, move_actions, move_zones, move_events, move_event_snapshots, move_thresholds, move_nonces, move_rate_limits, move_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 11 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):

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 move_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 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:

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 move_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):

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 404s 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.