Les konfigurasjon fra config.toml, med env-overstyring

Alt som gjorde appen «Sauda Gathering» – tittel, undertittel, aksentfarge,
hurtigfraser – var hardkodet i index.html. Dette legger grunnlaget for å
flytte det ut, så samme kode kan brukes på et annet arrangement uten å
redigere HTML.

Tre lag, siste vinner:  DEFAULTS -> config.toml -> ANNOUNCER_*

config.toml er valgfri. Uten den gjelder DEFAULTS, som er dagens oppførsel
bit for bit. Det er et krav, ikke en forglemmelse: tjenesten skal kunne
settes opp på en kveld uten at noe må konfigureres først.

Hemmeligheter hører i env-laget, så de kan stå i systemd-enheten istedenfor
i en fil i repoet. config.toml er gitignorert; config.example.toml er malen.

Egen modul og ikke en del av announcer.py fordi auth-koden som kommer
trenger den også, og det ville blitt en import-sirkel.

Ingenting bruker den ennå – det skjer i neste commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-08 15:42:52 +02:00
parent f191fb2b90
commit 87066bb8c2
3 changed files with 402 additions and 0 deletions

4
.gitignore vendored
View file

@ -2,3 +2,7 @@
cache/ cache/
voices/*.onnx* voices/*.onnx*
__pycache__/ __pycache__/
config.toml
logs/
instance/
.pytest_cache/

123
config.example.toml Normal file
View file

@ -0,0 +1,123 @@
# Kopier til config.toml og endre det du vil ha annerledes. Alt er valgfritt:
# uten fila kjører appen med standardverdiene nedenfor, uten innlogging.
#
# cp config.example.toml config.toml
#
# Hver nøkkel kan overstyres av en miljøvariabel, se tabellen i AGENTS.md.
# Hemmeligheter bør settes slik istedenfor å stå her, f.eks. i systemd-enheten:
# Environment=ANNOUNCER_JWT_SECRET=...
# config.toml er i .gitignore.
[app]
title = "Announcer"
subtitle = "Skriv meldingen, forhåndslytt, og send den ut på anlegget."
accent = "#4fd1c5"
idle_text = "Stille · ingenting i kø"
message_placeholder = "Skriv meldingen som skal leses opp …"
max_chars = 600
default_voice = "finn"
default_chime = "dingdong"
# Knapper over tekstfeltet. Tom liste = ingen knapper.
quick_phrases = [
"Pizzaen er kommet. Hent i kantina!",
"Turneringen starter om ti minutter.",
"Husk å rydde rundt plassen din. Takk!",
]
[server]
host = "0.0.0.0"
port = 8090
# Slå på bare når appen står bak HTTPS. Setter Secure på sesjonscookien; på
# rå HTTP ville ingen klart å logge inn med den på.
behind_tls = false
# Trengs bare bak en omvendt proxy, for at OIDC-redirect skal peke riktig.
# public_url = "https://announcer.example.no"
# Tom = nøkkel genereres én gang og lagres i instance/secret_key.
# secret_key = ""
[paths]
# Kommenter inn hvis noe skal ligge et annet sted enn ved siden av koden.
# voices = "voices"
# cache = "cache"
# chimes = "chimes"
# log = "logs/announcements.jsonl"
# state = "instance"
[audio]
# pw-play --target / paplay --device. Tom = systemets standardutgang.
# Finn navnet med: pactl list short sinks
sink = ""
# --------------------------------------------------------------------------
# Innlogging
# --------------------------------------------------------------------------
# Tom liste her og ingen av blokkene under utfylt = alt er åpent. Det er
# standarden, og helt greit på et lukket nett.
#
# En metode er aktiv når den står i providers *eller* når den er ferdig
# konfigurert nedenfor. Derfor holder det å sette en PIN for å slå på PIN.
# Rekkefølgen her bestemmer hvilken som prøves først.
[auth]
providers = []
# providers = ["oidc", "jwt", "pin"]
# --- PIN: én delt kode. Virker uten nett, så den er verdt å ha i reserve. ---
[auth.pin]
pin = ""
role = "admin"
label = "PIN"
hint = "Spør vaktleder"
# --- JWT: crew-panelet signerer et token. Se docs/crew-integration.md. -----
[auth.jwt]
issuer = "https://crew.example.no"
audience = "announcer"
# Ett av disse to. Delt hemmelighet er enklest; JWKS er bedre fordi
# announcer da aldri ser en nøkkel som kan lage tokens.
# secret = "sett-via-ANNOUNCER_JWT_SECRET"
# jwks_url = "https://crew.example.no/.well-known/jwks.json"
# Tom = HS256 med secret, RS256/ES256 med jwks_url.
algorithms = []
# Hvor rollene står i tokenet. Punktum for nestet, f.eks. "realm_access.roles".
role_claim = "roles"
# Hvilken rolle alle med gyldig token får hvis tokenet ikke sier noe.
default_role = ""
# Hvor lenge sesjonen fra /auth/token varer, hvis tokenet ikke har kortere exp.
session_seconds = 43200
# Slingringsmonn på exp/nbf i sekunder, for klokker som ikke står helt likt.
# En maskin på et arrangement har ikke alltid NTP i orden.
leeway = 30
# --- OIDC: vanlig innlogging i nettleseren, autorisasjonskode med PKCE. ----
[auth.oidc]
issuer = "https://crew.example.no"
client_id = ""
# client_secret = "sett-via-ANNOUNCER_OIDC_CLIENT_SECRET"
scopes = ["openid", "profile", "email"]
role_claim = "roles"
default_role = ""
label = "Logg inn med crew-kontoen"
# Bare hvis panelet mangler .well-known/openid-configuration:
# authorization_endpoint = "https://crew.example.no/oauth/authorize"
# token_endpoint = "https://crew.example.no/oauth/token"
# jwks_url = "https://crew.example.no/.well-known/jwks.json"
# end_session_endpoint = "https://crew.example.no/logout"
# --------------------------------------------------------------------------
# Roller
# --------------------------------------------------------------------------
# Rollenavnene kommer fra tokenet (role_claim). Handlinger:
# view se køen og hva som spilles nå
# preview forhåndslytte (synteser lyd)
# announce sende ut på anlegget
# stop stoppe det som spilles
# clear tømme køen
# "*" betyr alt.
[roles]
# Alle som ikke er innlogget. ["view"] er nyttig for en statusskjerm på veggen.
anonymous = []
crew = ["view", "preview", "announce"]
admin = ["*"]

275
config.py Normal file
View file

@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Konfigurasjon: valgfri config.toml pluss miljøvariabler.
Appen virker helt uten config.toml – da gjelder DEFAULTS, som er den gamle
oppførselen: ingen innlogging, norsk tekst, samme mapper som før.
Tre lag, der det siste vinner:
DEFAULTS -> config.toml -> ANNOUNCER_*-miljøvariabler
Hemmeligheter (PIN, JWT-nøkkel, OIDC-client_secret) hører hjemme i det siste
laget, så de kan stå i systemd-enheten istedenfor i en fil som ligger i repoet.
Egen modul fordi både announcer.py og auth.py trenger den; ville blitt en
import-sirkel om den bodde i announcer.py.
"""
from __future__ import annotations
import copy
import os
import tomllib
from pathlib import Path
from typing import Any, Callable
HERE = Path(__file__).resolve().parent
# Alt man kan få lov til å gjøre. Roller er navngitte knipper av disse.
ACTIONS = ("view", "preview", "announce", "stop", "clear")
DEFAULTS: dict[str, Any] = {
"app": {
"title": "Announcer",
"subtitle": "Skriv meldingen, forhåndslytt, og send den ut på anlegget.",
"accent": "#4fd1c5",
"idle_text": "Stille · ingenting i kø",
"message_placeholder": "Skriv meldingen som skal leses opp …",
"max_chars": 600,
"quick_phrases": [],
"default_voice": "finn",
"default_chime": "dingdong",
},
"paths": {
"voices": str(HERE / "voices"),
"cache": str(HERE / "cache"),
"chimes": str(HERE / "chimes"),
"log": str(HERE / "logs" / "announcements.jsonl"),
"state": str(HERE / "instance"),
},
"audio": {
# pw-play --target / paplay --device. Tom = systemets standardutgang.
"sink": "",
},
"server": {
"host": "0.0.0.0",
"port": 8090,
# Settes bare når appen står bak HTTPS. Styrer Secure-flagget på
# sesjonscookien; på i rå HTTP betyr ingen innlogging i det hele tatt.
"behind_tls": False,
"secret_key": "",
# Full URL utenfra, trengs for OIDC redirect_uri bak en proxy.
"public_url": "",
},
"auth": {
# Tom liste = alt åpent. Se active_providers(): en leverandør er også
# aktiv når den er ferdig konfigurert, så det holder å sette
# ANNOUNCER_PIN eller ANNOUNCER_JWT_SECRET.
"providers": [],
},
"auth.pin": {
"pin": "",
# admin, så dagens PIN-brukere beholder stopp og tøm kø.
"role": "admin",
"label": "PIN",
"hint": "",
},
"auth.jwt": {
"issuer": "",
"audience": "",
"secret": "",
"jwks_url": "",
"algorithms": ["HS256"],
"role_claim": "roles",
"name_claim": "",
"default_role": "",
# Hvor lenge en sesjon fra /auth/token varer, i sekunder.
"session_seconds": 43200,
"leeway": 30,
},
"auth.oidc": {
"issuer": "",
"client_id": "",
"client_secret": "",
"scopes": ["openid", "profile", "email"],
"role_claim": "roles",
"name_claim": "",
"default_role": "",
# Settes bare hvis panelet mangler discovery.
"authorization_endpoint": "",
"token_endpoint": "",
"jwks_url": "",
"end_session_endpoint": "",
"label": "Logg inn",
"verify_signature": True,
},
"roles": {
"anonymous": [],
"crew": ["view", "preview", "announce"],
"admin": ["*"],
},
}
def _bool(v: str) -> bool:
return v.strip().lower() in ("1", "true", "yes", "on", "ja")
def _list(v: str) -> list[str]:
return [p.strip() for p in v.replace(",", " ").split() if p.strip()]
# Miljøvariabel -> (sti i konfigtreet, konvertering).
ENV: dict[str, tuple[tuple[str, str], Callable[[str], Any]]] = {
"ANNOUNCER_TITLE": (("app", "title"), str),
"ANNOUNCER_SUBTITLE": (("app", "subtitle"), str),
"ANNOUNCER_ACCENT": (("app", "accent"), str),
"ANNOUNCER_MAX_CHARS": (("app", "max_chars"), int),
"ANNOUNCER_DEFAULT_VOICE": (("app", "default_voice"), str),
"ANNOUNCER_DEFAULT_CHIME": (("app", "default_chime"), str),
"ANNOUNCER_VOICES": (("paths", "voices"), str),
"ANNOUNCER_CACHE": (("paths", "cache"), str),
"ANNOUNCER_CHIMES": (("paths", "chimes"), str),
"ANNOUNCER_LOG": (("paths", "log"), str),
"ANNOUNCER_STATE": (("paths", "state"), str),
"ANNOUNCER_SINK": (("audio", "sink"), str),
"ANNOUNCER_HOST": (("server", "host"), str),
"ANNOUNCER_PORT": (("server", "port"), int),
"ANNOUNCER_BEHIND_TLS": (("server", "behind_tls"), _bool),
"ANNOUNCER_SECRET_KEY": (("server", "secret_key"), str),
"ANNOUNCER_PUBLIC_URL": (("server", "public_url"), str),
"ANNOUNCER_AUTH": (("auth", "providers"), _list),
"ANNOUNCER_PIN": (("auth.pin", "pin"), str),
"ANNOUNCER_PIN_ROLE": (("auth.pin", "role"), str),
"ANNOUNCER_JWT_ISSUER": (("auth.jwt", "issuer"), str),
"ANNOUNCER_JWT_AUDIENCE": (("auth.jwt", "audience"), str),
"ANNOUNCER_JWT_SECRET": (("auth.jwt", "secret"), str),
"ANNOUNCER_JWT_JWKS_URL": (("auth.jwt", "jwks_url"), str),
"ANNOUNCER_JWT_ALGORITHMS": (("auth.jwt", "algorithms"), _list),
"ANNOUNCER_JWT_ROLE_CLAIM": (("auth.jwt", "role_claim"), str),
"ANNOUNCER_JWT_DEFAULT_ROLE": (("auth.jwt", "default_role"), str),
"ANNOUNCER_OIDC_ISSUER": (("auth.oidc", "issuer"), str),
"ANNOUNCER_OIDC_CLIENT_ID": (("auth.oidc", "client_id"), str),
"ANNOUNCER_OIDC_CLIENT_SECRET": (("auth.oidc", "client_secret"), str),
"ANNOUNCER_OIDC_SCOPES": (("auth.oidc", "scopes"), _list),
"ANNOUNCER_OIDC_ROLE_CLAIM": (("auth.oidc", "role_claim"), str),
"ANNOUNCER_OIDC_DEFAULT_ROLE": (("auth.oidc", "default_role"), str),
}
def _merge(base: dict, over: dict) -> dict:
"""Dyp fletting. Lister erstattes, de slås ikke sammen."""
out = dict(base)
for k, v in over.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = _merge(out[k], v)
else:
out[k] = v
return out
def _flatten_tables(raw: dict) -> dict:
"""[auth.pin] i TOML blir {"auth": {"pin": …}}; vi vil ha "auth.pin" flatt.
Det gjør oppslag i resten av koden kjedelig enkelt, og det er bare auth
som har undertabeller.
"""
out = dict(raw)
auth = out.get("auth")
if isinstance(auth, dict):
rest = {}
for k, v in auth.items():
if isinstance(v, dict):
out[f"auth.{k}"] = _merge(out.get(f"auth.{k}", {}), v)
else:
rest[k] = v
out["auth"] = rest
return out
class Config:
def __init__(self, data: dict[str, Any], source: Path | None = None) -> None:
self.data = data
self.source = source
# -- oppslag ----------------------------------------------------------
def section(self, name: str) -> dict[str, Any]:
return self.data.get(name, {})
def get(self, section: str, key: str, default: Any = None) -> Any:
return self.data.get(section, {}).get(key, default)
@property
def app(self) -> dict[str, Any]:
return self.section("app")
@property
def roles(self) -> dict[str, list[str]]:
return self.section("roles")
def path(self, key: str) -> Path:
return Path(self.get("paths", key)).expanduser()
# -- auth -------------------------------------------------------------
def active_providers(self) -> list[str]:
"""Hvilke innloggingsmetoder som gjelder, i rekkefølge.
En metode er aktiv når den er nevnt i auth.providers *eller* når den er
ferdig konfigurert. Den siste halvdelen er det som gjør at
ANNOUNCER_PIN=1234 alene fortsatt slår på PIN, slik det har virket før,
og at et JWT-oppsett ikke må nevnes to steder.
"""
listed = [str(p).strip().lower() for p in self.get("auth", "providers", [])]
out: list[str] = []
for name in ("pin", "jwt", "oidc"):
if name in listed or self.configured(name):
out.append(name)
# Rekkefølgen i config vinner for de som er nevnt der.
out.sort(key=lambda n: listed.index(n) if n in listed else len(listed))
return out
def configured(self, provider: str) -> bool:
s = self.section(f"auth.{provider}")
if provider == "pin":
return bool(str(s.get("pin", "")).strip())
if provider == "jwt":
return bool(s.get("secret") or s.get("jwks_url"))
if provider == "oidc":
return bool(s.get("client_id") and (s.get("issuer") or s.get("authorization_endpoint")))
return False
def permissions(self, roles: tuple[str, ...]) -> frozenset[str]:
"""Rollenavn -> handlinger. "*" betyr alt."""
out: set[str] = set()
for role in roles:
for action in self.roles.get(role, []):
if action == "*":
return frozenset(ACTIONS)
if action in ACTIONS:
out.add(action)
return frozenset(out)
def load(path: str | Path | None = None) -> Config:
"""Leser konfigurasjonen. Manglende config.toml er helt greit."""
if path is None:
path = os.environ.get("ANNOUNCER_CONFIG", HERE / "config.toml")
path = Path(path).expanduser()
data = copy.deepcopy(DEFAULTS)
source: Path | None = None
if path.is_file():
with path.open("rb") as fh:
data = _merge(data, _flatten_tables(tomllib.load(fh)))
source = path
for env, ((section, key), cast) in ENV.items():
raw = os.environ.get(env)
if raw is None or not raw.strip():
continue
try:
data[section][key] = cast(raw.strip())
except (TypeError, ValueError) as e:
raise SystemExit(f"{env}: ugyldig verdi {raw!r} ({e})") from e
return Config(data, source)