프로젝트 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 다이제스트 일치).
282 lines
15 KiB
Markdown
282 lines
15 KiB
Markdown
# 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:
|
|
|
|
```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
|
|
<?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`):
|
|
|
|
```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
|
|
|
|
`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):
|
|
|
|
```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', '<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:
|
|
|
|
```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', '<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.
|