diff --git a/app/database.py b/app/database.py index e94c0bd..a73ef06 100644 --- a/app/database.py +++ b/app/database.py @@ -89,7 +89,13 @@ def decrypt_value(value: str) -> str: try: return _fernet.decrypt(value[len(_ENC_PREFIX) :].encode()).decode() except InvalidToken: - _logger.warning("Failed to decrypt config value — key may have changed") + _logger.warning( + "Failed to decrypt config value: the Fernet ENCRYPTION KEY (CASHPILOT_SECRET_KEY / " + "%s) does not match the key this value was encrypted with. This is NOT a bad " + "credential — re-enter the affected credentials, or restore the original encryption " + "key, to recover.", + _FERNET_KEY_FILE, + ) return "" diff --git a/app/deps.py b/app/deps.py index d6bdc64..7fb4558 100644 --- a/app/deps.py +++ b/app/deps.py @@ -11,13 +11,14 @@ from __future__ import annotations import ipaddress +import os from typing import Any from fastapi import HTTPException, Request from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates -from app import auth +from app import auth, setup_token __all__ = [ "templates", @@ -26,8 +27,15 @@ "_require_writer", "_require_owner", "_require_private_network", + "_require_first_run_access", + "client_ip", ] +# Opt-in: set CASHPILOT_TRUSTED_PROXY=1 only when the app sits behind exactly one +# reverse proxy you control. X-Forwarded-For is attacker-controlled, so we ignore +# it unless the operator asserts a trusted proxy is stripping/appending it. +_TRUST_PROXY = os.getenv("CASHPILOT_TRUSTED_PROXY", "").strip().lower() in ("1", "true", "yes", "on") + templates = Jinja2Templates(directory="app/templates") @@ -57,13 +65,48 @@ def _require_owner(request: Request) -> dict[str, Any]: return user +def client_ip(request: Request) -> str | None: + """Best-effort real client IP. + + Behind a trusted reverse proxy (opt-in via ``CASHPILOT_TRUSTED_PROXY``) the + real peer is the right-most ``X-Forwarded-For`` entry — the value appended by + the trusted proxy, which a client cannot forge by prepending its own. Without + that opt-in we never trust the header and use the direct peer. + """ + if _TRUST_PROXY: + xff = request.headers.get("x-forwarded-for", "") + parts = [p.strip() for p in xff.split(",") if p.strip()] + if parts: + return parts[-1] + return request.client.host if request.client else None + + def _require_private_network(request: Request) -> None: - """Block requests from public IPs (for first-run setup).""" - if not request.client or not request.client.host: + """Block requests whose real client IP is public (first-run defense in depth).""" + ip_str = client_ip(request) + if not ip_str: return try: - client_ip = ipaddress.ip_address(request.client.host) + ip = ipaddress.ip_address(ip_str) except ValueError: return - if not (client_ip.is_loopback or client_ip.is_private): + if not (ip.is_loopback or ip.is_private): raise HTTPException(status_code=403, detail="First-run setup only allowed from private networks") + + +def _require_first_run_access(request: Request, setup_token_value: str | None = None) -> None: + """Gate first-run owner creation: private network AND the one-time setup token. + + The network check alone is spoofable behind a reverse proxy (the peer is then + the proxy), so the setup token — printed to the server logs, readable only + with host access — is the real gate. The token is accepted from the explicit + argument (a form field), the ``setup_token`` query param, or the + ``X-Setup-Token`` header. + """ + _require_private_network(request) + token = setup_token_value or request.query_params.get("setup_token") or request.headers.get("x-setup-token") + if not setup_token.verify(token): + raise HTTPException( + status_code=403, + detail="First-run setup requires the setup token printed in the server logs", + ) diff --git a/app/exchange_rates.py b/app/exchange_rates.py index 8e879a6..765db3b 100644 --- a/app/exchange_rates.py +++ b/app/exchange_rates.py @@ -108,6 +108,7 @@ def get_all() -> dict[str, Any]: "fiat": dict(_fiat_rates), "crypto_usd": dict(_crypto_usd), "last_updated": _last_fetch, + "stale": rates_stale(), } diff --git a/app/main.py b/app/main.py index b1c361f..cdeb292 100644 --- a/app/main.py +++ b/app/main.py @@ -31,7 +31,7 @@ from pydantic import BaseModel from starlette.middleware.base import BaseHTTPMiddleware -from app import auth, catalog, compose_generator, database, exchange_rates, fleet_key, metrics +from app import auth, catalog, compose_generator, database, exchange_rates, fleet_key, metrics, setup_token logging.basicConfig( level=logging.INFO, @@ -44,6 +44,30 @@ # In-memory store for the latest collector alerts (errors from last run) _collector_alerts: list[dict[str, str]] = [] _collection_lock = asyncio.Lock() +_collection_semaphore = asyncio.Semaphore(8) + +# Fire-and-forget background tasks (e.g. triggered collection runs). Keeping a +# reference prevents the task from being garbage-collected mid-run and lets us +# retrieve/log any exception it raised (bare `asyncio.create_task(...)` drops +# the reference and silently swallows exceptions). +_background_tasks: set[asyncio.Task] = set() + + +def _spawn(coro) -> asyncio.Task: + """Fire-and-forget a coroutine while keeping a reference and logging errors.""" + task = asyncio.create_task(coro) + _background_tasks.add(task) + + def _on_done(t: asyncio.Task) -> None: + _background_tasks.discard(t) + if not t.cancelled(): + exc = t.exception() + if exc is not None: + logger.error("Background task failed: %s", exc, exc_info=exc) + + task.add_done_callback(_on_done) + return task + # Login rate limiting _login_attempts: dict[str, list[float]] = defaultdict(list) @@ -51,16 +75,16 @@ _LOGIN_WINDOW_SECONDS = 300 -def _check_login_rate(client_ip: str) -> None: +def _check_login_rate(ip: str) -> None: now = monotonic() - attempts = _login_attempts[client_ip] - _login_attempts[client_ip] = [t for t in attempts if now - t < _LOGIN_WINDOW_SECONDS] - if len(_login_attempts[client_ip]) >= _LOGIN_MAX_ATTEMPTS: + attempts = _login_attempts[ip] + _login_attempts[ip] = [t for t in attempts if now - t < _LOGIN_WINDOW_SECONDS] + if len(_login_attempts[ip]) >= _LOGIN_MAX_ATTEMPTS: raise HTTPException(status_code=429, detail="Too many login attempts. Try again in a few minutes.") -def _record_failed_login(client_ip: str) -> None: - _login_attempts[client_ip].append(monotonic()) +def _record_failed_login(ip: str) -> None: + _login_attempts[ip].append(monotonic()) def _safe_json(raw: str, fallback: Any = None) -> Any: @@ -179,6 +203,12 @@ async def _run_health_check() -> None: logger.warning("Health check skipped: %s", exc) +async def _collect_bounded(collector) -> Any: + """Run a single collector's `collect()` under the shared concurrency limit.""" + async with _collection_semaphore: + return await collector.collect() + + async def _run_collection() -> None: """Collect earnings from all deployed services that have collectors.""" global _collector_alerts @@ -186,9 +216,10 @@ async def _run_collection() -> None: logger.info("Collection already in progress, skipping") return async with _collection_lock: - start_time = metrics.record_collection_start() success = True + start_time = 0.0 try: + start_time = metrics.record_collection_start() deployments = await database.get_deployments() config = await database.get_config() or {} if not isinstance(config, dict): @@ -197,7 +228,7 @@ async def _run_collection() -> None: collectors = make_collectors(deployments, config) await _close_stale() - results = await asyncio.gather(*(c.collect() for c in collectors), return_exceptions=True) + results = await asyncio.gather(*(_collect_bounded(c) for c in collectors), return_exceptions=True) alerts: list[dict[str, str]] = [] platforms_ok = 0 for result in results: @@ -242,6 +273,15 @@ async def _run_data_retention() -> None: logger.warning("Data retention error: %s", exc) +async def _run_vacuum() -> None: + """Reclaim disk left by retention deletes (SQLite does not auto-shrink).""" + try: + await database.vacuum_database() + logger.info("Database VACUUM complete") + except Exception as exc: + logger.warning("Database VACUUM error: %s", exc) + + async def _check_stale_workers() -> None: """Mark workers as offline if stale, and purge workers offline > 1 hour.""" try: @@ -281,6 +321,21 @@ async def lifespan(app: FastAPI): _changed = _u.get("password_changed_at") or 0.0 if _changed: auth.set_user_pwd_epoch(_u["id"], _changed) + # First-run setup token: while no users exist, require a one-time token + # (printed below) for /register so a proxy-exposed instance cannot be seized + # by the first public visitor. Persisted in config so it survives restarts; + # cleared once the owner account is created. + if not await database.has_any_users(): + _tok = await database.get_config("_setup_token") + if not _tok: + _tok = setup_token.generate() + await database.set_config("_setup_token", _tok) + setup_token.set_active(_tok) + logger.warning( + "FIRST-RUN SETUP: no account exists yet. Create the owner account at " + "/register?setup_token=%s — this token is required and is shown only here.", + _tok, + ) catalog.load_services() catalog.register_sighup() @@ -324,6 +379,15 @@ def _on_job_event(event): coalesce=True, misfire_grace_time=300, ) + scheduler.add_job( + _run_vacuum, + "interval", + weeks=1, + id="db_vacuum", + max_instances=1, + coalesce=True, + misfire_grace_time=300, + ) scheduler.add_job( exchange_rates.refresh, "interval", @@ -335,7 +399,7 @@ def _on_job_event(event): ) scheduler.start() await exchange_rates.refresh() - asyncio.create_task(_run_collection()) + _spawn(_run_collection()) logger.info("CashPilot UI started (container ops via workers)") yield @@ -393,9 +457,11 @@ async def dispatch(self, request, call_next): from app.deps import ( # noqa: E402 _login_redirect, # noqa: F401 (re-exported for app.main.* test/router surface) _require_auth_api, + _require_first_run_access, # noqa: F401 (re-exported for app.main.* router surface) _require_owner, _require_private_network, # noqa: F401 (re-exported for app.main.* router surface) _require_writer, + client_ip, # noqa: F401 (re-exported for app.main.* router surface) templates, # noqa: F401 (re-exported for app.main.* router/test surface) ) @@ -528,6 +594,10 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]: "health_score": health.get("score"), "uptime_pct": health.get("uptime_pct"), "restarts_7d": health.get("restarts", 0), + "crashes_7d": health.get("crashes", 0), + # "unstable" flags a service that has crashed repeatedly in the health window + # so the dashboard can surface it at a glance (not just via the score number). + "unstable": health.get("crashes", 0) >= 3, "instances": len(agg["instances"]), "instance_details": instance_details, "collector_disconnected": slug in alert_slugs, @@ -567,6 +637,8 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]: "health_score": None, "uptime_pct": None, "restarts_7d": 0, + "crashes_7d": 0, + "unstable": False, "instances": 0, "instance_details": [], "collector_disconnected": slug in alert_slugs, @@ -750,7 +822,7 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id await database.save_deployment(slug=slug, container_id=container_id) await database.record_health_event(slug, "start", f"deployed to worker {worker_id}") metrics.record_container_lifecycle("deploy", slug) - asyncio.create_task(_run_collection()) + _spawn(_run_collection()) return {"status": "deployed", "container_id": container_id} @@ -759,6 +831,7 @@ async def api_stop(request: Request, slug: str, worker_id: int | None = None) -> _require_writer(request) worker_id = await _resolve_worker_id(worker_id) result = await _proxy_worker_command(worker_id, "stop", slug) + await database.record_health_event(slug, "stop") metrics.record_container_lifecycle("stop", slug) return result @@ -768,6 +841,7 @@ async def api_restart(request: Request, slug: str, worker_id: int | None = None) _require_writer(request) worker_id = await _resolve_worker_id(worker_id) result = await _proxy_worker_command(worker_id, "restart", slug) + await database.record_health_event(slug, "restart") metrics.record_container_lifecycle("restart", slug) return result @@ -781,6 +855,7 @@ async def api_remove( params = {"delete_volumes": "true"} if delete_volumes else None result = await _proxy_worker_command(worker_id, "remove", slug, params=params) await database.remove_deployment(slug) + await database.record_health_event(slug, "remove") metrics.record_container_lifecycle("remove", slug) return result @@ -872,6 +947,9 @@ def _validate_worker_url(raw_url: str) -> str: Resolves hostnames and validates the resolved IP(s) so a DNS name that points at a metadata/loopback address is rejected (DNS-rebinding guard). + Synchronous — its only blocking op is DNS resolution; event-loop callers + MUST invoke it via ``asyncio.to_thread`` (see _get_verified_worker_url) so a + slow/hanging resolver never blocks the whole UI. """ parsed = urlparse(raw_url) if parsed.scheme not in _ALLOWED_WORKER_SCHEMES: @@ -918,7 +996,7 @@ def _validate_worker_url(raw_url: str) -> str: return raw_url.rstrip("/") -def _get_verified_worker_url(worker: dict[str, Any]) -> tuple[str, dict[str, str]]: +async def _get_verified_worker_url(worker: dict[str, Any]) -> tuple[str, dict[str, str]]: """Validate a worker record and return (url, headers).""" if not worker: raise HTTPException(status_code=404, detail="Worker not found") @@ -926,7 +1004,7 @@ def _get_verified_worker_url(worker: dict[str, Any]) -> tuple[str, dict[str, str raise HTTPException(status_code=503, detail="Worker is offline") if not worker["url"]: raise HTTPException(status_code=503, detail="Worker URL not known") - url = _validate_worker_url(worker["url"]) + url = await asyncio.to_thread(_validate_worker_url, worker["url"]) headers: dict[str, str] = {} if FLEET_API_KEY: headers["Authorization"] = f"Bearer {FLEET_API_KEY}" @@ -938,7 +1016,7 @@ async def _proxy_worker_command( ) -> dict[str, Any]: """Forward a container command (restart/stop/start/remove) to a worker.""" worker = await database.get_worker(worker_id) - url, headers = _get_verified_worker_url(worker) + url, headers = await _get_verified_worker_url(worker) try: async with httpx.AsyncClient(timeout=30) as client: @@ -958,7 +1036,7 @@ async def _proxy_worker_command( async def _proxy_worker_deploy(worker_id: int, slug: str, spec: dict[str, Any]) -> dict[str, Any]: """Forward a deploy command with full spec to a worker.""" worker = await database.get_worker(worker_id) - url, headers = _get_verified_worker_url(worker) + url, headers = await _get_verified_worker_url(worker) try: async with httpx.AsyncClient(timeout=60) as client: @@ -975,7 +1053,7 @@ async def _proxy_worker_deploy(worker_id: int, slug: str, spec: dict[str, Any]) async def _proxy_worker_logs(worker_id: int, slug: str, lines: int = 50) -> dict[str, str]: """Forward a logs request to a worker.""" worker = await database.get_worker(worker_id) - url, headers = _get_verified_worker_url(worker) + url, headers = await _get_verified_worker_url(worker) try: async with httpx.AsyncClient(timeout=30) as client: @@ -1004,6 +1082,7 @@ async def api_service_restart(request: Request, slug: str, worker_id: int | None worker_id = await _resolve_worker_id(worker_id) result = await _proxy_worker_command(worker_id, "restart", slug) await database.record_health_event(slug, "restart") + metrics.record_container_lifecycle("restart", slug) return result @@ -1013,6 +1092,7 @@ async def api_service_stop(request: Request, slug: str, worker_id: int | None = worker_id = await _resolve_worker_id(worker_id) result = await _proxy_worker_command(worker_id, "stop", slug) await database.record_health_event(slug, "stop") + metrics.record_container_lifecycle("stop", slug) return result @@ -1022,6 +1102,7 @@ async def api_service_start(request: Request, slug: str, worker_id: int | None = worker_id = await _resolve_worker_id(worker_id) result = await _proxy_worker_command(worker_id, "start", slug) await database.record_health_event(slug, "start") + metrics.record_container_lifecycle("start", slug) return result @@ -1043,6 +1124,8 @@ async def api_service_remove( params = {"delete_volumes": "true"} if delete_volumes else None result = await _proxy_worker_command(worker_id, "remove", slug, params=params) await database.remove_deployment(slug) + await database.record_health_event(slug, "remove") + metrics.record_container_lifecycle("remove", slug) return result @@ -1235,7 +1318,7 @@ async def api_health_scores(request: Request, days: int = 7) -> list[dict[str, A @app.post("/api/collect") async def api_collect(request: Request) -> dict[str, str]: _require_writer(request) - asyncio.create_task(_run_collection()) + _spawn(_run_collection()) return {"status": "collection_started"} @@ -1325,7 +1408,7 @@ async def api_set_preferences(request: Request, body: PreferencesUpdate) -> dict ) # If setup is completed, trigger an immediate collection if body.setup_completed: - asyncio.create_task(_run_collection()) + _spawn(_run_collection()) return {"status": "saved"} @@ -1403,17 +1486,11 @@ async def api_collectors_meta(request: Request) -> list[dict[str, Any]]: _require_owner(request) from app.collectors import _COLLECTOR_ARGS, COLLECTOR_MAP - secret_args = { - "password", - "token", - "auth_token", - "access_token", - "api_key", - "session_cookie", - "auth_cookie", - "oauth_token", - "brd_sess_id", - } + # Single-sourced from database.SECRET_CONFIG_KEYS so this endpoint can never + # disagree with the encryption-at-rest / masking logic about which config + # keys are secret (a hand-maintained duplicate here previously missed + # `remember_web` and `xsrf_token`, unmasking them). + secret_args = database.SECRET_CONFIG_KEYS # Per-service hints on how to obtain the credentials hints: dict[str, str] = { "bitping": "Use your Bitping account email and password (same as nodes.bitping.com).", @@ -1705,10 +1782,15 @@ class WorkerCommand(BaseModel): @app.post("/api/workers/{worker_id}/command") async def api_worker_command(request: Request, worker_id: int, body: WorkerCommand) -> dict[str, Any]: """Send a command to a worker by proxying to its REST API.""" - _require_writer(request) + # Deploy is owner-gated everywhere else (see /api/deploy/{slug}); a writer + # must not be able to bypass that gate by sending command="deploy" here. + if body.command == "deploy": + _require_owner(request) + else: + _require_writer(request) worker = await database.get_worker(worker_id) - url, headers = _get_verified_worker_url(worker) + url, headers = await _get_verified_worker_url(worker) try: async with httpx.AsyncClient(timeout=30) as client: diff --git a/app/metrics.py b/app/metrics.py index 9046bc1..cbe52d4 100644 --- a/app/metrics.py +++ b/app/metrics.py @@ -17,12 +17,15 @@ from __future__ import annotations import contextlib +import logging import os import re import time from fastapi import FastAPI +logger = logging.getLogger(__name__) + METRICS_ENABLED = os.getenv("CASHPILOT_METRICS_ENABLED", "").lower() in ("1", "true", "yes") _registry = None @@ -231,6 +234,15 @@ def setup(app: FastAPI) -> None: _init_metrics() + # /metrics is served UNAUTHENTICATED (Prometheus convention). It exposes earnings and + # health totals, so warn on startup: keep it behind a reverse proxy / auth and never + # expose the app's port directly to an untrusted network. + logger.warning( + "Prometheus /metrics is ENABLED and served UNAUTHENTICATED — it exposes your " + "earnings and health data to anyone who can reach this port. Keep it behind a " + "reverse proxy with auth; do not expose the port directly to an untrusted network." + ) + from fastapi import Response from prometheus_client import CONTENT_TYPE_LATEST, generate_latest from starlette.middleware.base import BaseHTTPMiddleware diff --git a/app/routers/auth.py b/app/routers/auth.py index aa8ff62..fe53613 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -45,7 +45,7 @@ async def do_login( username: str = Form(...), password: str = Form(...), ): - client_ip = request.client.host if request.client else "unknown" + client_ip = main.client_ip(request) or "unknown" try: main._check_login_rate(client_ip) except HTTPException: @@ -86,7 +86,7 @@ async def page_register(request: Request, error: str = ""): if not user or user.get("r") != "owner": return RedirectResponse("/login", status_code=303) if is_first: - main._require_private_network(request) + main._require_first_run_access(request) return main.templates.TemplateResponse( request, @@ -99,6 +99,8 @@ async def page_register(request: Request, error: str = ""): "button_text": "Create Account", "error": error, "is_first": is_first, + # Carry the setup token through the form so the POST passes the same gate. + "setup_token": request.query_params.get("setup_token", "") if is_first else "", }, ) @@ -109,6 +111,7 @@ async def do_register( username: str = Form(...), password: str = Form(...), password_confirm: str = Form(...), + setup_token: str = Form(""), ): is_first = not await main.database.has_any_users() @@ -119,7 +122,7 @@ async def do_register( raise HTTPException(status_code=403, detail="Only owners can add users") if is_first: - main._require_private_network(request) + main._require_first_run_access(request, setup_token) if not re.match(r"^[a-zA-Z0-9_-]{3,32}$", username): return main.templates.TemplateResponse( @@ -133,6 +136,7 @@ async def do_register( "button_text": "Create Account", "error": "Username must be 3-32 alphanumeric characters (a-z, 0-9, _ -)", "is_first": is_first, + "setup_token": setup_token if is_first else "", }, status_code=400, ) @@ -149,6 +153,7 @@ async def do_register( "button_text": "Create Account", "error": "Passwords do not match", "is_first": is_first, + "setup_token": setup_token if is_first else "", }, status_code=400, ) @@ -165,6 +170,7 @@ async def do_register( "button_text": "Create Account", "error": "Password must be at least 10 characters", "is_first": is_first, + "setup_token": setup_token if is_first else "", }, status_code=400, ) @@ -182,6 +188,7 @@ async def do_register( "button_text": "Create Account", "error": "Username already taken", "is_first": is_first, + "setup_token": setup_token if is_first else "", }, status_code=400, ) @@ -191,6 +198,11 @@ async def do_register( hashed = main.auth.hash_password(password) user_id = await main.database.create_user(username, hashed, role) + if is_first: + # Owner now exists — retire the one-time setup token permanently. + await main.database.delete_config_keys(["_setup_token"]) + main.setup_token.clear() + token = main.auth.create_session_token(user_id, username, role) dest = "/setup" if is_first else "/" response = RedirectResponse(dest, status_code=303) diff --git a/app/routers/users.py b/app/routers/users.py index 2b2bed9..26f21f2 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -7,6 +7,7 @@ from __future__ import annotations +import time from typing import Any from fastapi import APIRouter, HTTPException, Request @@ -43,6 +44,10 @@ async def api_update_user_role(request: Request, user_id: int, body: UserRoleUpd if owner_count <= 1: raise HTTPException(status_code=400, detail="Cannot remove the last owner") await main.database.update_user_role(user_id, body.role) + # Invalidate any outstanding session tokens for this user — they carry the + # old role and must not be trusted until re-issued (same mechanism used + # for password changes). + main.auth.set_user_pwd_epoch(user_id, time.time()) return {"status": "updated"} @@ -55,4 +60,8 @@ async def api_delete_user(request: Request, user_id: int) -> dict[str, str]: if not user: raise HTTPException(status_code=404, detail="User not found") await main.database.delete_user(user_id) + # Invalidate any outstanding session tokens for the now-deleted account. + # The epoch cache is in-memory keyed by uid, so it survives the row being + # gone from the DB — decode_session_token rejects the token on iat alone. + main.auth.set_user_pwd_epoch(user_id, time.time()) return {"status": "deleted"} diff --git a/app/setup_token.py b/app/setup_token.py new file mode 100644 index 0000000..cf95235 --- /dev/null +++ b/app/setup_token.py @@ -0,0 +1,62 @@ +"""First-run setup-token gate. + +The very first account created on a fresh install becomes the ``owner``. The +private-network check in :mod:`app.deps` is not enough on its own: behind a +reverse proxy ``request.client`` is the *proxy* (a loopback/private address), so +the network check always passes and the first person to reach ``/register`` from +the public internet could seize the owner account. + +This module adds a proxy-independent second factor: a one-time token generated at +startup while no users exist, printed to the container logs. Only someone who can +read the server logs (i.e. already has host access) can complete first-run setup. +Once the owner account is created the token is cleared and never required again +(further users are added by an authenticated owner). + +The active token is held in a module global so the synchronous request guard can +check it without a DB round-trip; it is also persisted in the ``config`` table so +it survives restarts until consumed. +""" + +from __future__ import annotations + +import hmac +import secrets + +# The token currently required for first-run registration, or ``None`` when no +# first-run gate is active (owner already exists, or not yet initialised). +_active: str | None = None + + +def generate() -> str: + """Return a fresh, URL-safe setup token.""" + return secrets.token_urlsafe(24) + + +def set_active(token: str | None) -> None: + """Install (or clear, with ``None``) the token required for first-run setup.""" + global _active + _active = token or None + + +def clear() -> None: + """Drop the first-run gate — called once the owner account exists.""" + set_active(None) + + +def active() -> str | None: + """Return the currently required setup token, or ``None`` if none is active.""" + return _active + + +def verify(provided: str | None) -> bool: + """Check a caller-supplied token against the active one. + + Returns ``True`` when no token is active (nothing to enforce) or when the + supplied value matches in constant time; ``False`` otherwise. + """ + current = _active + if current is None: + return True + if not provided: + return False + return hmac.compare_digest(current, provided) diff --git a/app/static/js/app.js b/app/static/js/app.js index 4458645..fdfe957 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -75,13 +75,19 @@ const CP = (() => { function sanitizeHint(html) { const el = document.createElement('div'); el.innerHTML = html; + const allowedSchemes = ['http:', 'https:', 'mailto:']; el.querySelectorAll('*').forEach(node => { if (!['A', 'B', 'CODE'].includes(node.tagName)) { node.replaceWith(document.createTextNode(node.textContent)); return; } for (const attr of [...node.attributes]) { - if (node.tagName === 'A' && (attr.name === 'href' || attr.name === 'target')) continue; + if (node.tagName === 'A' && attr.name === 'href') { + const scheme = (attr.value || '').trim().toLowerCase(); + if (!allowedSchemes.some(s => scheme.startsWith(s))) node.removeAttribute('href'); + continue; + } + if (node.tagName === 'A' && attr.name === 'target') continue; node.removeAttribute(attr.name); } }); @@ -178,7 +184,9 @@ const CP = (() => { async function loadExchangeRates() { try { _exchangeRates = await api('/api/exchange-rates'); - } catch { /* keep defaults */ } + } catch (err) { + console.warn('Could not refresh exchange rates, keeping previous values:', err); + } } async function loadTopbarEarnings() { @@ -186,8 +194,9 @@ const CP = (() => { await loadExchangeRates(); const data = await api('/api/earnings/summary'); setTextContent('topbar-total', formatCurrency(data.total || 0)); - } catch { - setTextContent('topbar-total', formatCurrency(0)); + } catch (err) { + // Keep whatever was already shown rather than fabricating $0. + console.warn('Could not refresh topbar earnings, keeping previous value:', err); } } @@ -239,12 +248,9 @@ const CP = (() => { setChangeIndicator('month-change', data.month_change); } } catch (err) { - // API not yet implemented — fill with placeholder - setTextContent('total-earnings', formatCurrency(0)); - setTextContent('today-earnings', formatCurrency(0)); - setTextContent('month-earnings', formatCurrency(0)); - setTextContent('active-services', '0'); - setTextContent('topbar-total', formatCurrency(0)); + // Keep whatever was already displayed — a transient fetch failure + // must not look like the dashboard's earnings dropped to zero. + toast(err.message || 'Could not refresh dashboard', 'error'); } } @@ -401,9 +407,15 @@ const CP = (() => { } }); } catch (err) { - // Keep existing table if we had one; only show spinner on truly empty state + // Keep the existing table on a refresh failure. Only replace the + // perpetual "Loading..." spinner with a clear error + retry affordance + // when there was nothing on screen yet. if (!container.querySelector('.breakdown-table')) { - container.innerHTML = `