Ctrl K
ComunidadGuías…

config.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
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()