config.py
Rama main · réplica de lectura del repositorio original.
"""
Shared configuration constants for the Pieria application.
Extracted from app.py to break circular import dependencies.
"""
import os
import re
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# --- Version + update channel (ADR-071) --------------------------------------------------------------
# Single source of truth for the running version. Bump this WHEN YOU CUT A RELEASE and tag the commit
# `vX.Y.Z` — the update check compares this against the latest GitHub Release, and sd-update checks out
# that tag. Keep the two in lockstep: the tag is what ships, this is what the box believes it is running.
APP_VERSION = "1.1.3"
# owner/repo whose GitHub Releases define "latest". Public info; overridable for a fork.
UPDATE_REPO = os.getenv("SD_UPDATE_REPO", "pieria-art/Pieria").strip()
# C1: AI enrichment sometimes emits Markdown emphasis (e.g. "*The Irish Question*"). The placard and
# /art page render plain text, so the markers show literally. Flatten inline emphasis to plain prose.
# Lives here (dep-free) so app.py, curator.py and agents.py all share one implementation; mirrored by
# stripMd() in static/app.js for the client-rendered Canvas placard.
_MD_STRIP = [
(re.compile(r"\*\*([^*]+)\*\*"), r"\1"), (re.compile(r"\*([^*]+)\*"), r"\1"),
(re.compile(r"__([^_]+)__"), r"\1"), (re.compile(r"_([^_]+)_"), r"\1"),
(re.compile(r"`([^`]+)`"), r"\1"), (re.compile(r"\[([^\]]+)\]\([^)]*\)"), r"\1"),
(re.compile(r"^#{1,6}\s+", re.MULTILINE), ""),
]
def strip_markdown(s: str | None) -> str:
s = s or ""
for pat, repl in _MD_STRIP:
s = pat.sub(repl, s)
return s
ARTWORK_ROOT = Path(os.getenv("ARTWORK_ROOT", "Artwork"))
LIBRARY_DIR = ARTWORK_ROOT / "_Library"
# Where the static frontend (admin/help/studio/remote pages + the JS/CSS bundle) is served from.
# Lives here (not app.py) so both app.py's static mount and routers/pages.py's page routes share
# one constant instead of two independently-defined `Path("static")` literals.
STATIC_DIR = Path("static")
# Namespace prefix for federated-subscription pseudo-collection ids in the catalog browse surface
# (e.g. "sub_3") — keeps them from ever colliding with, or masquerading as, a bundled/official
# collection id. Shared by routers/federation.py (mints `sub_<id>`) and app.py's catalog-merge
# internals (which consume it to detect/resolve a subscription id).
SUB_PREFIX = "sub_"
# Wikimedia (and most museum/image hosts) reject the default httpx User-Agent; every outbound image
# fetch must send this descriptive UA. Lives here (not app.py) so the offline tools/ scripts can
# reuse it without importing the FastAPI app.
SD_USER_AGENT = "Pieria/1.0 (https://github.com/pieria-art/Pieria; art display) httpx"
# Modular-pack registry (ADR-040 #4 / ADR-038 §5): the public packs.json the "browse & download packs"
# card reads to offer on-demand collections. Default = the official Cloudflare R2 host behind curwe.ai;
# overridable per-install via the `pack_registry_url` setting (e.g. to point at a staging registry).
PACK_REGISTRY_URL = os.getenv("SD_PACK_REGISTRY_URL", "https://packs.curwe.ai/packs.json")
# Test/CI escape hatch (F1 hang, 2026-08-29): the OOB first-boot seed (core/lifespan.seed_from_registry)
# reaches the real network at app startup, which a GH runner can't (403s forever) — starting a retry
# loop that used to survive shutdown. Set by tests/conftest.py before `app` is imported so no test
# TestClient ever spawns it; production never sets this. Tests that exercise the seed on purpose
# (tests/test_oob_seed.py) monkeypatch it back off.
DISABLE_BOOT_SEED = os.getenv("SD_DISABLE_BOOT_SEED", "").strip() == "1"
# Deployment mode. Only the all-in-one appliance compose override sets SD_APPLIANCE_MODE=all-in-one;
# the generic/MS-01 server and thin-client (display-only) topologies leave it unset. Gates the
# host-health console + the GUI update bridge — surfaces that only make sense when the server runs
# ON the device being managed. A thin client's admin is served by a remote box that lacks this flag,
# so those surfaces correctly never appear there.
APPLIANCE_MODE = os.getenv("SD_APPLIANCE_MODE", "").strip().lower() # "" | "all-in-one"
IS_APPLIANCE = APPLIANCE_MODE == "all-in-one"
# Where the appliance bridge exchanges files with the host helper. ./data is already bind-mounted to
# /app/data, so the unprivileged container can write here and a root systemd watcher can read it.
APPLIANCE_DIR = Path(os.getenv("APPLIANCE_DIR", "data/appliance"))
# Admin Backup & Restore (ADR-138's reflash path). Both live under ./data (already bind-mounted +
# gitignored/dockerignored wholesale) so nothing here ever ends up in the repo or the image. Split into
# two dirs, not one, because they have different lifetimes: backups are ephemeral downloads (1h TTL,
# single-use), staged restores persist across a container restart into core/restore_boot.py's boot-time
# apply. Overridable so tests can point at a tmp dir instead of the real ./data.
BACKUP_DIR = Path(os.getenv("SD_BACKUP_DIR", "data/_backups"))
RESTORE_DIR = Path(os.getenv("SD_RESTORE_DIR", "data/_restore"))
# --- Security posture (ADR-036: no-login LAN kiosk kept honest by scoped CORS + gated mutations) -----
# The app has no auth by design (ADR-013/015) — the trust boundary is "you are a device on my LAN".
# Wildcard CORS previously widened that to "any browser tab on the LAN", so state-changing requests now
# carry an Origin allowlist check (see app.py). Same-origin (the kiosk's own page) is always allowed;
# add extra LAN origins here only to drive the API cross-origin from another device.
ALLOWED_ORIGINS = [o.strip() for o in os.getenv("SD_ALLOWED_ORIGINS", "").split(",") if o.strip()]
# --- Public demo mode (demo.pieria.org) -----------------------------------------------------------
# SD_DEMO_MODE=1 puts the app behind a default-DENY ASGI gate (core/demo.py) so an anonymous internet
# visitor gets a read-only browse of pre-baked art — no auth exists (ADR-013/015), so this is the ONLY
# thing standing between the public internet and every mutating route. Off (default) -> zero behavior
# change; every other route in this file is untouched by demo mode.
DEMO_MODE = os.getenv("SD_DEMO_MODE", "").strip() == "1"
# Comma-separated pack ids the lifespan leader installs (if not already present) when DEMO_MODE is on,
# via the existing on-demand registry installer — e.g. "masterpieces,impressionism". Empty = no boot
# bootstrap (an operator seeded the demo box by hand).
DEMO_PACKS = [p.strip() for p in os.getenv("SD_DEMO_PACKS", "").split(",") if p.strip()]
# Optional: the gallery `/` plays when demo mode is on (sets the `default_playlist` setting at boot,
# same key the Canvas already reads via /api/displays/{id}/preferred-playlist). Empty = leave whatever
# the installed pack(s) chose as default.
DEMO_DEFAULT_PLAYLIST = os.getenv("SD_DEMO_DEFAULT_PLAYLIST", "").strip()
# Shared secret gating the appliance update bridge (/api/appliance/update — the highest-consequence
# action: it can force a host git reset+rebuild or reboot). N6: fail CLOSED. The endpoint accepts EITHER
# a same-origin browser request (the Origin allowlist check in app.py) OR a request carrying a matching
# X-Appliance-Token header — a caller with neither (e.g. curl/any other LAN device, with no token set)
# is refused outright, not waved through as "prior behavior". install.sh and sd-update each mint a
# random token into .env on first run if one isn't already present, so an appliance box always ends up
# with one configured; a warning is logged only in the (unexpected) case none is set at request time.
APPLIANCE_UPDATE_TOKEN = os.getenv("SD_APPLIANCE_UPDATE_TOKEN", "").strip()