감사에서 확정된 잔여 항목을 일괄 처리. 이전 커밋이 증상(스테일 번들)을 고쳤다면 이번에는 그것이 조용히 지나가게 만든 구조를 고친다. 보안 - CSP 도입. 인라인 2블록에 per-request nonce, unsafe-inline 없음. 빌드가 없다는 제약이 nonce를 막지는 않는다. 보안 헤더를 .htaccess 단독에서 PHP로 옮겨 개발/납품이 같아진다. - /logout에 CSRF. SameSite=Lax가 막아주지만, 사고 대응 중 운영자를 콘솔에서 튕겨내는 건 이 제품에선 실제 가용성 공격이라 예외로 둘 이유가 없다. - 잠긴 계정 423 → 401. 아무나 이름을 추측해 5회 실패로 잠근 뒤 423을 계정 실재 확인으로 쓸 수 있었다. 잠금 사실은 감사 로그에 남고, 익명 호출자는 못 읽는다. 엣지 - 큐 flush에 회당 상한. 정전 복구 직후 2000건을 비우는 동안 감지가 멈추던 구간을 없앤다. - 타이머 env 전수 검증. COOLDOWN_S=nan은 dedup을 무력화해 매 틱 발보시켰다. - T2 프레임 스킵(INFER_EVERY). 스킵 틱은 프레임을 읽어 버퍼만 비우고 링버퍼에 넣지 않는다 — 추론이 없으면 얼굴 박스도 없어 그대로 넣으면 증거 스냅샷에 미블러 얼굴이 실린다. T3(모션 게이트)는 의도적으로 미구현: 게이트가 틀리면 '움직이지 않는 쓰러진 작업자'에게 추론이 안 돈다. 실영상 없이 임계값을 정할 수 없다. - systemd 워치독(sd_notify) + 메모리 상한. Restart=always는 죽은 프로세스만 잡고 '살아 있는데 멈춘' 상태는 못 잡는다. UVC 해상도·버퍼 고정(링버퍼 RSS가 웹캠 기본값에 좌우). 테스트 — 감사의 최대 갭 - PDO 통합 테스트가 pdo_sqlite 부재로 통째로 스킵되고 CI도 없어 사실상 한 번도 안 돌았다. `-d extension=pdo_sqlite`로 되살리고 리포트·히트맵·익스포트 집계 SQL을 최초로 실행한다. - 얼굴 블러 테스트가 out is not None만 봤다 — blur()가 무동작이어도 통과. PIPA 대응의 유일한 실행 가능한 증거였는데 아무것도 증명하지 못했다. 디테일 파괴를 단언하도록 교체. - index.php 라우트 테이블 대조(프런트 호출 15개 교차검증), dist↔소스 패리티 테스트, CI 신설(스킵이 하나라도 있으면 실패). - ArraySessionStore.regenerate()가 데이터를 지워 실제 session_regenerate_id(true)와 달랐다. 더블이 실구현과 어긋난 자리 하나를 실제로 잡았다. 프런트 - 이벤트 목록 시그니처 재렌더 스킵 + 포커스 복원. aria-live 영역을 5초마다 통째로 다시 만들어 스크린리더가 200행을 재낭독하고 키보드 포커스가 body로 날아갔다. 같은 패턴이 device-tree.js에는 이미 있었다. - 폴링 중첩 가드(느린 공유호스팅에서 응답 역전), 콜드스타트 실패 표시. 납품 - VERSION 신설, 두 산출물에 version/git_commit/built_at 스탬프. 스테일 번들의 SHA256SUMS는 자기정합적이라 통과한다 — provenance 없이는 하류에서 탐지할 방법이 없다. - deploy/README·env.example을 Hailo 단독 번들 실제와 일치(템플릿이 없는 ONNX를 가리켰다). - 이벤트 보존 정책은 기본 0(영구). 산업안전 기록을 시스템이 임의로 지우지 않는다. 검증: pytest 251 / PHPUnit 162, 스킵 0. CSP nonce 결합은 실제 렌더 경로로 확인. 브라우저 실렌더는 MySQL이 필요해 이 환경에서 미수행.
282 lines
16 KiB
Bash
Executable file
282 lines
16 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Build the Cafe24 upload package: a folder whose contents are dragged into the hosting
|
|
# account's FTP root as-is.
|
|
#
|
|
# THIS SCRIPT NEVER RUNS ON CAFE24. It runs here, on the build machine, and only produces a
|
|
# folder. The hosting account executes nothing — no shell, no composer, no installer: the
|
|
# operator uploads files over FTP and edits config.php. That is also why this lives under
|
|
# server/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as
|
|
# root on the target.
|
|
#
|
|
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
|
|
# server/deploy/build_cafe24_package.sh
|
|
#
|
|
# Output: dist/1_cafe24_upload/ and dist/move-detect-cafe24.zip
|
|
#
|
|
# dist/1_cafe24_upload/
|
|
# ├── move_detect_app/ -> UPLOAD to the FTP root. Not web-reachable.
|
|
# │ ├── config.php the ONLY file the operator edits
|
|
# │ ├── src/ vendor/ storage/snapshots/
|
|
# │ └── schema.sql import via phpMyAdmin, then DELETE from the host
|
|
# ├── www/
|
|
# │ └── move_detect/ -> UPLOAD into the existing www/. Reached at
|
|
# │ ├── index.php https://<domain>/move_detect/
|
|
# │ └── .htaccess _preflight.php assets/
|
|
# └── README-UPLOAD.md -> for the operator to read; not uploaded
|
|
#
|
|
# Two upload targets, and everything private is ONE folder. That matters on a shared account:
|
|
# this www/ already serves another site, so the FTP root may hold that site's files too —
|
|
# spraying src/, vendor/ and config.php across the root risks overwriting them (vendor/ and
|
|
# config.php are ordinary names in any PHP project).
|
|
#
|
|
# The app is mounted in a SUBDIRECTORY of www/ for the same reason: nothing secret may sit
|
|
# under a docroot the other site also serves. index.php finds move_detect_app/ by walking up
|
|
# rather than hardcoding '/..', and the URL prefix comes from SCRIPT_NAME, so this same
|
|
# package also works dropped straight into www/.
|
|
#
|
|
# APP_DIR= override the www/ subdirectory; empty mounts the app directly in www/.
|
|
# PRIVATE_DIR= override the private folder name (must match index.php's APP_PRIVATE_DIR).
|
|
set -euo pipefail
|
|
|
|
APP_DIR="${APP_DIR-move_detect}"
|
|
PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
SERVER="$REPO_ROOT/server"
|
|
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
|
|
STAGE="$OUT_DIR/1_cafe24_upload"
|
|
ZIP="$OUT_DIR/move-detect-cafe24.zip"
|
|
|
|
command -v composer >/dev/null 2>&1 || { echo "ERROR: composer not found on the build machine." >&2; exit 1; }
|
|
command -v php >/dev/null 2>&1 || { echo "ERROR: php not found on the build machine." >&2; exit 1; }
|
|
|
|
echo ">> [1/6] clean stage: $STAGE"
|
|
rm -rf "$STAGE"
|
|
# WEB is where the app's own files land: www/move_detect by default, www/ when APP_DIR is
|
|
# empty. Everything else stays at the stage root = the FTP root = one level above www/.
|
|
WEB="$STAGE/www${APP_DIR:+/$APP_DIR}"
|
|
# PRIV holds everything that must never be web-reachable, in ONE folder so it cannot
|
|
# collide with the co-hosted site's files at the FTP root.
|
|
PRIV="$STAGE/$PRIVATE_DIR"
|
|
mkdir -p "$WEB" "$PRIV/src" "$PRIV/storage/snapshots"
|
|
|
|
echo ">> [2/6] generating the no-dev autoloader"
|
|
# public/index.php hard-requires ../vendor/autoload.php, so vendor/ MUST ship — the app has
|
|
# no runtime composer *packages* (composer.lock's "packages" is empty), but it does need
|
|
# composer's generated autoloader to map App\ -> src/.
|
|
#
|
|
# --no-dev is load-bearing, not tidiness: the working tree's vendor/ carries PHPUnit and a
|
|
# dev autoload_files.php that eagerly requires tests/Support/InMemoryRepositories.php on
|
|
# every request — a file this package deliberately excludes. Shipping it fatals every hit.
|
|
#
|
|
# --classmap-authoritative because src/Auth/Session.php declares three symbols
|
|
# (SessionStore, ArraySessionStore, PhpSessionStore) under one filename matching none of
|
|
# them; that 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's
|
|
# `new PhpSessionStore()` — the 4th statement of every request.
|
|
#
|
|
# --no-interaction so a build box without a TTY cannot hang. Composer rewrites server/vendor/
|
|
# in place, so restore the dev autoloader on exit or local phpunit breaks.
|
|
restore_dev_autoloader() {
|
|
echo ">> restoring the dev autoloader in server/vendor (so local phpunit keeps working)"
|
|
( cd "$SERVER" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true
|
|
}
|
|
trap restore_dev_autoloader EXIT
|
|
|
|
( cd "$SERVER" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction )
|
|
|
|
# Precondition for everything below: this package stages ONLY composer's generated
|
|
# autoloader, which is correct exactly while the app has no runtime packages. The day
|
|
# someone adds one to composer.json's `require`, that staging silently omits its code and
|
|
# the host fatals on a missing class. Fail here, on the commit that adds the dependency,
|
|
# rather than on the hosting account.
|
|
php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? 0 : 1);' \
|
|
"$SERVER/composer.lock" || {
|
|
echo "ERROR: composer.lock now lists runtime packages. This script stages only the" >&2
|
|
echo " autoloader, so their code would be missing from the package. Teach" >&2
|
|
echo " step [3/6] to stage the real vendor tree before shipping again." >&2
|
|
exit 1
|
|
}
|
|
|
|
echo ">> [3/6] staging vendor/ (autoloader only — no phpunit, no dev deps)"
|
|
mkdir -p "$PRIV/vendor/composer"
|
|
cp "$SERVER/vendor/autoload.php" "$PRIV/vendor/autoload.php"
|
|
# Copy only composer's own generated files, by name. A glob over vendor/* would drag in
|
|
# phpunit/, sebastian/, myclabs/ and friends.
|
|
for f in ClassLoader.php InstalledVersions.php autoload_classmap.php autoload_namespaces.php \
|
|
autoload_psr4.php autoload_real.php autoload_static.php platform_check.php; do
|
|
cp "$SERVER/vendor/composer/$f" "$PRIV/vendor/composer/$f"
|
|
done
|
|
cp "$SERVER/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true
|
|
|
|
# installed.php / installed.json are deliberately NOT copied. `dump-autoload` does not
|
|
# regenerate them — they are the package inventory written by the last `composer install`,
|
|
# so they still list every dev package (and its exact version, which is a free CVE shopping
|
|
# list for anyone who can read them). Nothing here needs them: autoload_real.php eagerly
|
|
# requires only platform_check.php, and Composer\InstalledVersions reads installed.php
|
|
# lazily, on a call this app never makes.
|
|
|
|
# Guard: the dev autoload_files.php requires tests/Support/InMemoryRepositories.php, which
|
|
# is not in this package. If it slipped through, every request 500s — fail the build loudly
|
|
# rather than ship it.
|
|
if [ -f "$PRIV/vendor/composer/autoload_files.php" ]; then
|
|
echo "ERROR: autoload_files.php present — that is the DEV autoloader (it requires tests/)." >&2
|
|
echo " composer dump-autoload --no-dev did not take effect." >&2
|
|
exit 1
|
|
fi
|
|
# Guard: no autoload map may point outside src/. Grep the maps that actually drive loading
|
|
# rather than the whole tree — InstalledVersions.php names PHPUnit in a docblock, which is
|
|
# harmless prose, and matching it would make this check cry wolf.
|
|
for map in autoload_classmap.php autoload_psr4.php autoload_static.php autoload_namespaces.php; do
|
|
if grep -qiE "phpunit|sebastian|myclabs|/tests/" "$PRIV/vendor/composer/$map"; then
|
|
echo "ERROR: $map references dev deps or tests/ — --no-dev did not take effect." >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
# Guard: no dev package directory may exist in the staged vendor/.
|
|
if find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer | grep -q .; then
|
|
echo "ERROR: unexpected package directories under the staged vendor/:" >&2
|
|
find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo ">> [4/6] staging app source"
|
|
cp -r "$SERVER/src/." "$PRIV/src/"
|
|
cp -r "$SERVER/public/." "$WEB/"
|
|
cp "$SERVER/schema.sql" "$PRIV/schema.sql"
|
|
# migrations/ ships too: a host that already imported an older schema.sql upgrades by
|
|
# running these (phpMyAdmin, date order) instead of re-importing — schema.sql is for a
|
|
# FRESH database only. Like schema.sql, delete from the host after applying.
|
|
cp -r "$SERVER/migrations" "$PRIV/migrations"
|
|
# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
|
|
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
|
|
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
|
|
# Provenance stamp. A package's own contents are always self-consistent, so nothing inside it
|
|
# reveals that it was built before the last four feature commits — which is exactly how a stale
|
|
# package once shipped. Keep it OUT of the web root: it names the build, not a public asset.
|
|
{
|
|
echo "package: move-detect-cafe24"
|
|
echo "version: $(cat "$REPO_ROOT/VERSION" 2>/dev/null || echo unknown)"
|
|
echo "git_commit: $(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
|
echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
} > "$PRIV/BUILD-INFO.txt"
|
|
cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php"
|
|
# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On).
|
|
cp "$SERVER/deploy/user.ini" "$WEB/.user.ini"
|
|
cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
|
|
|
|
# `cp -r` above copies whatever is in server/src|public — defensively strip dev/tooling caches
|
|
# so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served
|
|
# package. Legit dotfiles (.htaccess/.user.ini/.gitkeep) are named explicitly, not globbed.
|
|
find "$WEB" "$PRIV" -depth \( -name '.ruff_cache' -o -name '.impeccable' -o -name '__pycache__' \
|
|
-o -name '.pytest_cache' -o -name '.mypy_cache' -o -name '.DS_Store' \
|
|
-o -name '.phpunit.result.cache' -o -name 'Thumbs.db' \) -exec rm -rf {} + 2>/dev/null || true
|
|
|
|
# storage/snapshots must exist and be writable. Nothing in src/ ever creates it, and the
|
|
# failure is silent rather than loud: IngestController does
|
|
# `if (file_put_contents(...) === false) continue;`, so a missing dir means the snapshot is
|
|
# dropped while the API still answers 201 to the edge device. The event row lands in MySQL
|
|
# with no image, the dashboard shows an event with no photo, and with display_errors=Off
|
|
# (correct for production) not even a warning survives. That is permanent evidence loss.
|
|
touch "$PRIV/storage/snapshots/.gitkeep"
|
|
# Build-local only. Neither zip nor FTP carries unix modes, so these do NOT reach the host —
|
|
# they are correct for someone who rsyncs the stage directly, and a no-op otherwise. The
|
|
# host-side modes are the operator's job (README-UPLOAD.md) and _preflight.php verifies the
|
|
# live value rather than trusting these.
|
|
chmod 750 "$PRIV/storage/snapshots"
|
|
chmod 600 "$PRIV/config.php"
|
|
|
|
echo ">> [5/6] verifying the package resolves every class index.php touches"
|
|
# Prove the autoloader works against the STAGED layout, not the repo's.
|
|
php -r '
|
|
$stage = $argv[1];
|
|
require $stage . "/vendor/autoload.php";
|
|
$classes = ["App\\Config", "App\\Http\\Router", "App\\Http\\Request", "App\\Http\\Response",
|
|
"App\\Db\\Database", "App\\Auth\\PhpSessionStore", "App\\Auth\\AuthController",
|
|
"App\\Auth\\CsrfController", "App\\Auth\\Rbac", "App\\Ingest\\IngestController",
|
|
"App\\Ingest\\EventValidator", "App\\Ingest\\ImageProcessor", "App\\Api\\ConfigController",
|
|
"App\\Api\\HeartbeatController", "App\\Dashboard\\EventListController",
|
|
"App\\Dashboard\\EventDetailController", "App\\Dashboard\\SnapshotProxy",
|
|
"App\\Dashboard\\ZoneConfigController", "App\\Geometry\\Polygon",
|
|
"App\\Repository\\Pdo\\PdoDeviceRepository", "App\\Repository\\Pdo\\PdoNonceRepository",
|
|
"App\\Repository\\Pdo\\PdoRateLimiterRepository", "App\\Repository\\Pdo\\PdoEventRepository",
|
|
"App\\Repository\\Pdo\\PdoSnapshotRepository", "App\\Repository\\Pdo\\PdoUserRepository",
|
|
"App\\Repository\\Pdo\\PdoZoneRepository", "App\\Repository\\Pdo\\PdoThresholdRepository",
|
|
"App\\Repository\\Pdo\\PdoAuditRepository"];
|
|
$missing = array_values(array_filter($classes, fn($c) => !class_exists($c) && !interface_exists($c)));
|
|
if ($missing) { fwrite(STDERR, "ERROR: unresolvable in the package: " . implode(", ", $missing) . "\n"); exit(1); }
|
|
echo " -> " . count($classes) . " classes resolve through the packaged autoloader\n";
|
|
' "$PRIV"
|
|
|
|
# Prove index.php ACTUALLY finds $PRIV from where it is mounted, at the real depth, rather
|
|
# than trusting that the tree looks right. This is the check that would have caught a
|
|
# hardcoded '/..' — the walk-up must cross www/ and land on the private folder.
|
|
php -r '
|
|
[$_, $web, $priv] = $argv;
|
|
$found = null;
|
|
$isAppRoot = fn(string $d) => is_file("$d/config.php") && is_file("$d/vendor/autoload.php")
|
|
&& is_file("$d/src/Config.php");
|
|
for ($dir = dirname($web), $i = 0; $i < 4; $i++) {
|
|
foreach (["$dir/move_detect_app", $dir] as $c) { if ($isAppRoot($c)) { $found = $c; break 2; } }
|
|
if (dirname($dir) === $dir) break;
|
|
$dir = dirname($dir);
|
|
}
|
|
if ($found === null) { fwrite(STDERR, "ERROR: index.php could not resolve the app root from $web\n"); exit(1); }
|
|
if (realpath($found) !== realpath($priv)) {
|
|
fwrite(STDERR, "ERROR: app root resolved to $found, expected $priv\n"); exit(1);
|
|
}
|
|
echo " -> index.php resolves its app root across www/ to " . basename($found) . "/\n";
|
|
' "$WEB" "$PRIV"
|
|
|
|
# Every staged .php must parse under the build php. Catches a truncated copy.
|
|
while IFS= read -r -d '' f; do
|
|
php -l "$f" >/dev/null || { echo "ERROR: php -l failed: $f" >&2; exit 1; }
|
|
done < <(find "$STAGE" -name '*.php' -print0)
|
|
echo " -> every staged .php parses"
|
|
|
|
echo ">> [6/6] zipping"
|
|
rm -f "$ZIP"
|
|
if [ "${MAKE_ZIP:-0}" != "1" ]; then
|
|
# Default: leave the folder uncompressed. The operator uploads $STAGE/ over FTP as-is,
|
|
# which is also the only path that cannot mangle directory separators (see below).
|
|
echo " -> skipped (MAKE_ZIP=1 to also produce $ZIP)"
|
|
elif command -v zip >/dev/null 2>&1; then
|
|
( cd "$STAGE" && zip -rq "$ZIP" . )
|
|
elif command -v python3 >/dev/null 2>&1; then
|
|
# No `zip` (common on Windows/Git Bash). Use Python's zipfile with POSIX (forward-slash)
|
|
# arcnames. PowerShell's Compress-Archive writes BACKSLASH path separators, which Cafe24's
|
|
# extractor and some unzip tools do NOT normalise — the app tree then unpacks as flat files
|
|
# (move_detect_app\src\... as one filename) and the deploy silently breaks. Prefer python.
|
|
( cd "$STAGE" && python3 - "$ZIP" <<'PY'
|
|
import os, sys, zipfile
|
|
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as z:
|
|
for root, _dirs, files in os.walk("."):
|
|
for name in files:
|
|
full = os.path.join(root, name)
|
|
arc = os.path.relpath(full, ".").replace(os.sep, "/") # force forward slashes
|
|
z.write(full, arc)
|
|
PY
|
|
)
|
|
elif command -v powershell >/dev/null 2>&1; then
|
|
# Last resort. Compress-Archive uses backslash separators (see above) — the .htaccess/README
|
|
# note the operator to extract with a slash-aware tool, or prefer the python path.
|
|
win_stage="$(cygpath -w "$STAGE" 2>/dev/null || echo "$STAGE")"
|
|
win_zip="$(cygpath -w "$ZIP" 2>/dev/null || echo "$ZIP")"
|
|
powershell -NoProfile -Command "Compress-Archive -Path '${win_stage}\\*' -DestinationPath '${win_zip}' -Force"
|
|
echo " WARN: archived via Compress-Archive (backslash paths). Verify the host unpacks the tree correctly."
|
|
else
|
|
echo " WARN: no zip/python3/powershell — skipping the archive; upload $STAGE/ directly."
|
|
fi
|
|
|
|
echo ""
|
|
echo "DONE."
|
|
echo " package : $STAGE"
|
|
[ -f "$ZIP" ] && echo " zip : $ZIP"
|
|
echo ""
|
|
echo "Upload: put the CONTENTS of $STAGE into the Cafe24 FTP root."
|
|
echo " www/* merges into the existing www/ (DocumentRoot)"
|
|
echo " src/ vendor/ storage/ config.php sit BESIDE www/, never inside it"
|
|
echo ""
|
|
echo "Then, on the host: edit config.php (DB name/user/password), import schema.sql via"
|
|
echo "phpMyAdmin and delete it, and open https://<domain>/_preflight.php to confirm the PHP"
|
|
echo "build is usable — then DELETE _preflight.php. See README-UPLOAD.md."
|