From 95b98eebf1d66c747791b4d66bb904a14ade5786 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 23 Jun 2026 07:25:57 +0200 Subject: [PATCH] fix(compose): wire all documented backend env vars + regression test (0.87.12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.87.1 added 12+ env vars to `.env.example` but the compose backend `environment:` block forwarded none of them — `.env` interpolation doesn't pass vars to the container. Operators got anonymous-serving sites, silent ADMIN_* no-ops, and missing ANALYSIS_* configuration. Wire 36 backend-consumed vars; also substitute the hardcoded `BACKEND_URL` on the frontend; emit a startup warning on half-set `ADMIN_USERNAME`/`ADMIN_PASSWORD`. Regression-test parses docker-compose.yml and asserts the wiring. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/src/config.rs | 116 ++++++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 41 ++++++++++++++- frontend/package.json | 2 +- 5 files changed, 155 insertions(+), 8 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c7f1a48..0c74754 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.87.11" +version = "0.87.12" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1b41aae..f1c6d46 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.87.11" +version = "0.87.12" edition = "2021" default-run = "mangalord" diff --git a/backend/src/config.rs b/backend/src/config.rs index 0b449e0..9815ff6 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -468,11 +468,30 @@ impl Config { /// Returns `Some((username, password))` only when BOTH `ADMIN_USERNAME` /// and `ADMIN_PASSWORD` are set and non-empty. Half-set configuration is /// treated as "no bootstrap" rather than a hard error, so an operator -/// can comment out one env var without crashing the server. +/// can comment out one env var without crashing the server. Emits a +/// warning in the half-set case — silent no-op startled operators when +/// the documented compose env vars finally landed in 0.87.12 (a typo +/// in ADMIN_USERNAME used to silently disable bootstrap with no log +/// trace explaining why no admin appeared). fn admin_bootstrap_from_env() -> Option<(String, String)> { - let username = std::env::var("ADMIN_USERNAME").ok().filter(|s| !s.is_empty())?; - let password = std::env::var("ADMIN_PASSWORD").ok().filter(|s| !s.is_empty())?; - Some((username, password)) + let username = std::env::var("ADMIN_USERNAME").ok().filter(|s| !s.is_empty()); + let password = std::env::var("ADMIN_PASSWORD").ok().filter(|s| !s.is_empty()); + match (username, password) { + (Some(u), Some(p)) => Some((u, p)), + (Some(_), None) => { + tracing::warn!( + "ADMIN_USERNAME is set but ADMIN_PASSWORD is empty — admin bootstrap skipped (set both, or unset both to silence this warning)" + ); + None + } + (None, Some(_)) => { + tracing::warn!( + "ADMIN_PASSWORD is set but ADMIN_USERNAME is empty — admin bootstrap skipped (set both, or unset both to silence this warning)" + ); + None + } + (None, None) => None, + } } impl CrawlerConfig { @@ -803,5 +822,94 @@ mod tests { let cfg = Config::from_env().expect("from_env"); assert!(!cfg.auth.private_mode); } + + /// Regression test for the 0.87.1 compose env-wire-through gap. The + /// docker-compose backend service used to omit `environment:` entries + /// for `ADMIN_*`, `PRIVATE_MODE`, `ALLOW_SELF_REGISTER`, `CRAWLER_*`, + /// and `ANALYSIS_*`. Operators populating `.env` got an + /// anonymous-serving site, no admin bootstrap, and missing analysis + /// configuration because Docker Compose's `.env` interpolation only + /// expands inside this file — it does NOT pass vars into the + /// container's process env unless `environment:` (or `env_file:`) + /// names them. + /// + /// This test pins the wiring: every env var the backend reader + /// consumes must have a matching `KEY:` entry in the backend + /// service's `environment:` block. + #[test] + fn docker_compose_wires_every_documented_backend_env_var() { + // `.env.example` lines that ARE consumed by the backend. Lines + // that aren't (POSTGRES_*, TOR_CONTROL_PASSWORD, BACKEND_URL, + // BACKEND_PROXY_TIMEOUT_MS, VISION_MANAGER_*, the `VISION_*` + // sidecar-only vars) are deliberately excluded — keeping this + // list explicit so we get a compile-broken signal rather than + // a silently-passing test if a new var is added. + const BACKEND_CONSUMED: &[&str] = &[ + "BIND_ADDRESS", + "STORAGE_DIR", + "RUST_LOG", + "COOKIE_SECURE", + "COOKIE_DOMAIN", + "SESSION_TTL_DAYS", + "AUTH_RATE_PER_SEC", + "AUTH_RATE_BURST", + "CORS_ALLOWED_ORIGINS", + "ADMIN_ALLOWED_ORIGINS", + "ADMIN_USERNAME", + "ADMIN_PASSWORD", + "PRIVATE_MODE", + "ALLOW_SELF_REGISTER", + "MAX_REQUEST_BYTES", + "MAX_FILE_BYTES", + "CRAWLER_START_URL", + "CRAWLER_CDN_HOST", + "CRAWLER_LIMIT", + "CRAWLER_JOB_TIMEOUT_SECS", + "CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES", + "CRAWLER_BROWSER_RESTART_THRESHOLD", + "CRAWLER_DOWNLOAD_ALLOWLIST", + "CRAWLER_ALLOW_ANY_HOST", + "CRAWLER_MAX_IMAGE_BYTES", + "CRAWLER_CHROMIUM_BINARY", + "CRAWLER_PROXY", + "CRAWLER_TOR_CONTROL_URL", + "CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS", + "ANALYSIS_ENABLED", + "ANALYSIS_VISION_URL", + "ANALYSIS_VISION_MODEL", + "ANALYSIS_WORKERS", + "ANALYSIS_JOB_TIMEOUT_SECS", + "ANALYSIS_API_KEY", + "ANALYSIS_VISION_HEALTH_URL", + ]; + + // Cargo run dir for `cargo test` is `backend/`; compose file lives one up. + let compose = std::fs::read_to_string("../docker-compose.yml") + .expect("read ../docker-compose.yml"); + // Extract the backend service block: everything from ` backend:` + // up to (but not including) the next top-level service key. + let backend_block = compose + .split("\n backend:\n") + .nth(1) + .expect("backend service block") + .split("\n frontend:\n") + .next() + .expect("end of backend block (next service)"); + + let mut missing: Vec<&str> = Vec::new(); + for key in BACKEND_CONSUMED { + // Match ` KEY:` indent (6 spaces — under `environment:`). + let needle = format!(" {key}:"); + if !backend_block.contains(&needle) { + missing.push(*key); + } + } + assert!( + missing.is_empty(), + "docker-compose.yml backend service is missing env wiring for: {missing:?}. \ + Add `KEY: ${{KEY:-default}}` lines under backend.environment so the var \ + reaches the container." + ); + } } diff --git a/docker-compose.yml b/docker-compose.yml index 6f02d2d..bdc904c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,12 +75,39 @@ services: COOKIE_SECURE: ${COOKIE_SECURE:-true} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30} + AUTH_RATE_PER_SEC: ${AUTH_RATE_PER_SEC:-5} + AUTH_RATE_BURST: ${AUTH_RATE_BURST:-10} # CORS — same-origin by default; populate when serving the API on # a different host than the frontend. CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} + # Admin CSRF allowlist. Empty in default deploy → cookie-auth admin + # mutations are refused (fail-closed since 0.87.2). Set to the + # SvelteKit origin (e.g. https://app.example.com) for browser deploys. + ADMIN_ALLOWED_ORIGINS: ${ADMIN_ALLOWED_ORIGINS:-} + # Admin bootstrap. Half-set is treated as "skip bootstrap"; either + # both or neither. Set on first boot, then unset for subsequent + # deploys so the password isn't re-applied accidentally (existing + # users' passwords are never overwritten by this). + ADMIN_USERNAME: ${ADMIN_USERNAME:-} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + # Site-wide auth gates (env-only; never persisted to app_settings). + PRIVATE_MODE: ${PRIVATE_MODE:-false} + ALLOW_SELF_REGISTER: ${ALLOW_SELF_REGISTER:-true} # Upload limits. MAX_REQUEST_BYTES: ${MAX_REQUEST_BYTES:-209715200} MAX_FILE_BYTES: ${MAX_FILE_BYTES:-20971520} + # Crawler boot seeds (first-boot only; switch to dashboard-editable + # once the app_settings row exists). + CRAWLER_START_URL: ${CRAWLER_START_URL:-} + CRAWLER_CDN_HOST: ${CRAWLER_CDN_HOST:-} + CRAWLER_LIMIT: ${CRAWLER_LIMIT:-0} + CRAWLER_JOB_TIMEOUT_SECS: ${CRAWLER_JOB_TIMEOUT_SECS:-600} + CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES: ${CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES:-10} + CRAWLER_BROWSER_RESTART_THRESHOLD: ${CRAWLER_BROWSER_RESTART_THRESHOLD:-3} + # Crawler download safety — env-only, never persisted. + CRAWLER_DOWNLOAD_ALLOWLIST: ${CRAWLER_DOWNLOAD_ALLOWLIST:-} + CRAWLER_ALLOW_ANY_HOST: ${CRAWLER_ALLOW_ANY_HOST:-false} + CRAWLER_MAX_IMAGE_BYTES: ${CRAWLER_MAX_IMAGE_BYTES:-33554432} # System-chromium override for the crawler. Leave blank to use the # bundled fetcher; set to e.g. /usr/bin/chromium-headless-shell on # arm64 deployments. Pair with `--build-arg INSTALL_CHROMIUM=true` @@ -96,6 +123,15 @@ services: CRAWLER_TOR_CONTROL_URL: ${CRAWLER_TOR_CONTROL_URL-tcp://tor:9051} CRAWLER_TOR_CONTROL_PASSWORD: ${TOR_CONTROL_PASSWORD:?TOR_CONTROL_PASSWORD must be set in .env} CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS: ${CRAWLER_TOR_RECIRCUIT_MAX_ATTEMPTS:-3} + # Analysis worker (vision) — boot seeds + env-only API key. + ANALYSIS_ENABLED: ${ANALYSIS_ENABLED:-false} + ANALYSIS_VISION_URL: ${ANALYSIS_VISION_URL:-} + ANALYSIS_VISION_MODEL: ${ANALYSIS_VISION_MODEL:-} + ANALYSIS_WORKERS: ${ANALYSIS_WORKERS:-1} + ANALYSIS_JOB_TIMEOUT_SECS: ${ANALYSIS_JOB_TIMEOUT_SECS:-600} + # API key never gets persisted to app_settings; bearer-attached to + # every vision call. + ANALYSIS_API_KEY: ${ANALYSIS_API_KEY:-} # Vision readiness gate (env-ONLY, not a dashboard setting). When set, # the analysis worker refuses to lease a page until this answers 2xx, so # the vision-manager autoscaler can idle-stop the vision container @@ -120,7 +156,10 @@ services: environment: # SvelteKit's hooks.server.ts proxies /api/* to this URL so the # browser only ever talks to :3000 and cookies stay same-origin. - BACKEND_URL: http://backend:8080 + # `.env` override exists for deploys where the SvelteKit container + # needs to reach a backend on a different hostname (typical with + # an external reverse proxy in front of axum). + BACKEND_URL: ${BACKEND_URL:-http://backend:8080} # Per-request wall-clock cap for the /api/* reverse proxy. Defaults # to 300000 (5 min) in hooks.server.ts; raise/lower via .env. BACKEND_PROXY_TIMEOUT_MS: ${BACKEND_PROXY_TIMEOUT_MS:-300000} diff --git a/frontend/package.json b/frontend/package.json index a19fcbd..f2ae3bc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.87.11", + "version": "0.87.12", "private": true, "type": "module", "scripts": {