#!/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:///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" # tools/ ships INSIDE the private folder, never under www/. These are CLI entry points the host # runs from cron (notification dispatch, retention) or by hand (backup, audit verification), and # bootstrap.php refuses to run under any SAPI but CLI — but the real guarantee is that they are # not web-reachable at all, because dispatch_alerts.php sends mail and backup.php reads every # incident record in the database. cp -r "$SERVER/tools" "$PRIV/tools" # 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:///_preflight.php to confirm the PHP" echo "build is usable — then DELETE _preflight.php. See README-UPLOAD.md."