UI/UX 점검에서 시작해 "이 제품이 안전 시스템으로서 무엇이 없나"까지 내려간 작업. 두 덩어리를 한 커밋으로 둔다 — 뒤쪽 기능들이 앞쪽에서 고친 화면 위에 얹혀 있다. ━━ 1부: UI/UX 감사 21건 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 동선 — 확인(ack) 경로가 위험했다 - 스트림 정렬을 '미확인 우선'에서 엄격 시간순으로 되돌린다. 확인 상태가 연대기를 뒤집어 사고 재구성이 불가능했고, 확인하면 그 행이 목록 밖으로 사라지며 아래 행이 밀려 올라와 같은 좌표를 두 번 누르면 다른 이벤트가 확인 처리됐다. 이제 제자리에서 '확인됨'으로 전환되고 확정 토스트가 뜬다. 처리 큐는 정렬이 아니라 뷰모드 필터로 분리 (전환해도 행끼리 상대 순서는 절대 안 바뀐다). - 실행취소는 넣지 않았다. acknowledged_by는 감사로그가 함께 남는 추가 전용 기록이라 무음 되돌리기가 안전 시스템에서 더 나쁜 속성이다. 대신 오탐·구역삭제를 2단계 확정으로. - 이벤트 상세에 확인/오탐 신설. 판단하는 화면에 판단 도구가 없었다. - 구역 설정에 장치·카메라 선택기. 시스템이 아는 값을 사람에게 묻고 있었고, 플레이스홀더(device-01)마저 실제와 다른 포맷을 가르쳤다. - 날짜 조회(키셋 커서 시딩). 사고 조사 진입로가 아예 없어 '더 보기'를 반복해야 했다. - 경보음 세션 음소거(새 위급 도착 시 자동 해제). 끌 수 없는 반복음은 안전 콘솔이 통째로 꺼지는 경로다. 전달 — 같은 단어가 화면마다 다른 숫자를 가리켰다 - KPI '미확인'(최근 200건)과 24시간 브리프 '미확인'이 40px 간격으로 충돌했다. KPI에 실제 집계 범위를 명시. 중대=위급+높음을 인라인 정의. - 페이지 명칭 3종·날짜 포맷 3종 통일. '확인 필요' 칩을 전 심각도로 확장 (그전엔 확인 여부가 먼 컬럼의 버튼 유무로만 구분됐다). - 표본 게이트: 오탐율 '100% (1/1)'이 '33% (5/15)'보다 위에 뜨던 것, 최다 3건짜리 시간패턴 히트맵, 6→8건에 붙던 빨간 ▲33%. 개선조치 효과는 푸아송 잡음 범위(√before)를 넘어야 방향을 주장한다 — 안전회의에서 한 번 반박당하면 카드 전체가 신뢰를 잃는다. - 카메라 위험 순위가 중대>0이면 전부 빨강이라 순위 노릇을 못 했다. 상대 기준으로. 시각 — 화면마다 완성도가 달랐다 - 구역 설정·이벤트 상세를 카드 셸로 재구성. 둘 다 스타일 없는 기본 폼이었고, 납품 심사에서 "덜 만든 티"는 정확히 거기서 났다. - 히트맵 토글은 aria-pressed만 있고 CSS 규칙이 없어 켜졌는지 눈으로 알 수 없었다. - 존 색을 팔레트와 정합(팔레트 어디에도 없던 보라 제거), 채움+라벨칩+협착 점선. 캔버스에 1px 외곽선만 그리면 실제 공장 프레임에서 사라진다. ━━ 2부: 안전 플랫폼 (마이그레이션 2026-08-09-safety-platform.sql) ━━━ 통보 — 이 제품의 정의에 해당하는 구멍 지금까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐다. 새벽 3시에 넘어진 작업자는 DB에 행 하나를 남기고 아침을 맞았다. - 엣지 GPIO 경광등(edge/beacon.py). 인터넷·서버 없이 동작하는 유일한 경로라 어떤 현장이든 이것부터 설정한다. /api/config의 beacons로 배선을 받고, 하드웨어가 없거나 핀이 안 열리면 NullBeacon으로 떨어지되 그 사실은 반드시 로그로 남긴다. - 메일·웹훅 채널 + 크론 디스패처(tools/dispatch_alerts.php). 중복 발송은 읽고-쓰기가 아니라 (event,channel) UNIQUE로 막는다 — 크론 중첩 실행은 공유호스팅의 상수다. - 미확인 위급은 unacked_escalation 이벤트가 되어 상위 채널로 올라간다(event_uuid로 멱등). '아무도 보지 않았다'는 사실 자체를 사건으로 만든다. - 통보에 이미지는 싣지 않는다. 스냅샷은 PIPA 대상이고 메일링 리스트로 나가는 순간 인증 프록시가 지키던 통제를 벗어난다 — 링크만 보낸다. 조용한 실패 제거 - 카메라 화각 이동·가림·조도 저하 감지(detectors/integrity.py). 존 판정은 화각 고정을 전제로 서 있는데, 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 시스템은 계속 초록불이었다. numpy만 사용하고, 지나가는 사람과 구분하기 위해 지속시간을 요구한다. - 스토리지 여유 감시. 디스크가 차면 이미지 저장만 조용히 실패하고 보드는 멀쩡해 보인다. 끊겨 있던 배선 2건 - thresholds_from_config()가 정의만 있고 어디서도 호출되지 않았다. 서버에 표가 있고 /api/config가 성실히 실어 보내고 엣지가 파싱한 뒤 버렸다 — 관리자가 무엇을 바꿔도 장비는 부팅 시 edge.env 값으로 영원히 돌았다(edge/tuning.py). - 존·임계값 저장이 config_version을 올리지 않아 '설정 지연' 표시가 애초에 켜질 수 없었다. 지표는 있는데 그것을 켜는 사건이 없었다. 관리 화면 (그전에는 phpMyAdmin이 유일한 방법이었다) - 임계값 편집: 자유 텍스트가 아니라 카탈로그(범위·단위·설명)로 렌더한다. 범위 밖 값은 엣지가 기동 시 거부하는데 그때는 이미 공장 안 장비가 재시작 루프다. 키보드 앞에서 막는다. 목록에 없는 키는 저장 불가 — 장비가 조용히 무시할 값이 '설정됨'으로 보이면 안 된다. - 정비 억제: 사유 필수, 최대 8시간(한 교대), 자동 만료. 감지와 기록은 그대로 계속되고 통보만 보류되며 그 보류도 원장에 status='suppressed'로 남는다 — 조사에서 '경보가 났지만 의도적으로 withheld됐다'를 볼 수 있어야 한다. - 교대 인수인계, 위험성평가, 설비 성능 추이, 설치 점검(/setup — 실제 상태 조회 기반). 증거·규정 - 감사로그 해시체인. 공유호스팅은 append-only 권한을 줄 수 없으니 '막는' 대신 '숨길 수 없게' 만든다. 수정·삭제·우회삽입이 전부 탐지되고(tools/verify_audit_chain.php, 손상 시 exit 1), 체인 이전 행은 손상이 아니라 '검증 불가 구간'으로 따로 센다 — 정상 시스템이 매번 빨간불을 켜면 아무도 이 도구를 안 본다. - 사고조사 패키지 1클릭: 전후 타임라인 + 통보 이력(누구에게 닿았는가) + 위험요인 + 조치 이력 + 서명란. 인쇄 CSS로 그대로 제출 가능. 사고 다음 날 반나절을 쓸 사람은 없다. - 이벤트→조치 직접 기록, 현장별 롤업(전역 계정만). 운영 - 일괄 확인(위급은 의도적으로 제외·2단계 확정), 키보드 트리아지(J/K/A/F/Enter), 모바일 레이아웃(44px 타깃), 보존기간 정리(tools/maintain.php), 백업/복구(tools/backup.php — www 안 출력은 거부), 데모 시드(tools/seed_demo.php). - tools/는 전부 CLI 전용이고 납품 시 move_detect_app/tools/에 www 밖으로 배치된다. 미착수 4건 (모델·하드웨어가 선행 조건) - 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보 + HEF 컴파일이 관문이다. 감지기 배선·이벤트 계약·심각도·쿨다운·테스트까지 만들고 기본 비활성으로 둔다 (edge/detectors/pending.py). enabled=True인데 model=None이면 아무것도 하지 않도록 명시적으로 막았다 — "곧 됩니다" 상태로 켜 두면 현장은 감지되고 있다고 믿는다. - PLC 접점 협착 게이팅도 같은 이유로 인터페이스만. 검증: pytest 통과(skip 3 = 하드웨어·환경) / PHPUnit 221 (skip 0, +59). Chrome 실렌더로 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→ 행 변조→탐지(exit 1) 전 경로 확인. cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개) 재생성, 소스 패리티 통과. 보안 자체점검: SQL 문자열 보간 0, innerHTML 0, 상태 변경 핸들러 전부 CSRF+RBAC.
17 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_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 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).
- Open phpMyAdmin from the Cafe24 control panel, select the database created for this app.
- Import tab → choose file → upload
schema.sql→ Go. - Confirm all 11 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.
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/.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.
11. Scheduled jobs (cron)
Two jobs turn the installation from "records incidents" into "responds to incidents" and keep
the database inside a shared-hosting quota. Both live in move_detect_app/tools/ — inside the
private folder, never under www/ — and refuse to run under any SAPI but CLI.
# 통보 디스패처 — 통보 발송 + 미확인 에스컬레이션 + 실패 재시도.
# 이것이 돌지 않으면 감지는 되지만 아무에게도 전달되지 않는다 (콘솔을 보고 있는 사람 제외).
*/1 * * * * /usr/bin/php /home/<account>/move_detect_app/tools/dispatch_alerts.php >> /home/<account>/logs/alerts.log 2>&1
# 보존기간 정리 — 스냅샷(PIPA) · 성능 시계열 · nonce.
# 이벤트 기록은 event_retention_days 를 명시적으로 설정한 현장에서만 지워진다.
17 3 * * * /usr/bin/php /home/<account>/move_detect_app/tools/maintain.php >> /home/<account>/logs/maintain.log 2>&1
처음 켤 때는 반드시 dry-run 부터. 잘못된 수신자 목록으로 첫 발송을 하면 그 현장에서는 두 번 다시 이 기능을 켜지 않습니다.
php move_detect_app/tools/dispatch_alerts.php --dry-run
php move_detect_app/tools/maintain.php --dry-run
public_base_url 이 비어 있으면 디스패처는 실행을 거부합니다 — 열리지 않는 링크가 담긴
알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다 (config.php §4).
그 외 도구 (수동 실행)
| 도구 | 용도 |
|---|---|
tools/backup.php --out=<디렉터리> |
DB 덤프 + 스냅샷 아카이브 + 복구 안내. 출력 경로가 www/ 안이면 거부합니다. |
tools/verify_audit_chain.php |
감사로그 해시체인 검증. 손상 시 종료 코드 1 과 함께 끊긴 지점을 출력합니다. |
tools/seed_demo.php --days=45 |
시연·QA용 데모 데이터. 운영 DB 에서는 절대 실행하지 마십시오 (--purge 로 데모 행만 제거). |
12. Post-deploy: 설치 점검 화면
로그인 후 /setup 을 엽니다. 각 단계는 실제 상태를 조회해 판정하므로, 체크가 켜졌다면 그
단계는 정말로 동작하는 것입니다. 특히 ⑤ 통보 경로 가 비어 있으면 이 시스템은 감지 기록
장치일 뿐입니다 — 현장 경광등(엣지 GPIO)부터 설정하십시오. 인터넷이 끊겨도 동작하는 유일한
경로입니다.