«Send denne kl. 18:00.» Engangs, ikke gjentakende. Dette er den første delen av appen som må ligge på disk. Kø og historikk har alltid bare levd i minnet, og det har gått bra fordi en melding spilles med en gang. En melding planlagt til 18:00 må overleve at containeren startes om 17:45, så schedule.py lagrer i data/scheduled.json – skrevet til side og flyttet på plass, så fila aldri er halvskrevet. Ingen database, med vilje: dette er en håndfull meldinger på et arrangement, og en fil man kan lese med cat er verdt mer her enn indekser. En ødelagt fil hindrer ikke oppstart; da er det bedre å miste planene og si det i loggen. Modulen vet ingenting om Flask eller avspilling. Den lagrer, og den svarer på «hva skal sendes nå?». _scheduler() i announcer.py eier tråden som spør hvert 20. sekund og legger forfalte meldinger i den vanlige køen – derfra oppfører de seg nøyaktig som en melding sendt for hånd. Gikk den glipp av, spilles den ikke. due() skiller på grace_seconds (120): innenfor den sendes meldingen, utenfor markeres den missed og vises som det. Nådefristen er slingringsmonn for at tråden våkner med mellomrom og for en kjapp omstart, ikke en policy om å ta igjen etterslep – «turneringen starter om ti minutter» skal ikke ut på anlegget en time etter at maskina kom tilbake. due() endrer ingenting selv; markeringen skjer først etter at meldingen faktisk er i kø, så ingenting kan stå som «sendt» uten å ha blitt det. Klienten sender absolutt tidspunkt (epoch), så tidssoner spiller ingen rolle for selve planleggingen. TZ settes likevel fra .env, ellers står loggen i UTC mens man selv står i CEST. Planlegging er låst til samme forhåndslytt-nøkkel som «Send ut»: man planlegger bare det man har hørt. Det gir en bonus – forhåndslytt varmer cache/, så WAV-en ligger ferdig på disk, og en planlagt edge-tts-melding spiller kl. 18:00 selv om nettet er nede da. Sine egne kan man alltid avbryte. Andres krever clear, samme rett som å tømme køen, siden det er samme slags inngrep i noe andre har satt i gang. 23 nye tester. Verifisert i container: sending fyrer og spiller, overlever en omstart av containeren, og en melding som forfalt mens maskina var av blir markert missed uten å bli spilt. Merk ved oppgradering: schedule er en ny handling, og et oppsett med eksplisitt [roles] i config.toml får den ikke automatisk. Den må legges inn selv. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
287 lines
10 KiB
Python
287 lines
10 KiB
Python
#!/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", "schedule", "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"),
|
||
"schedule": str(HERE / "data" / "scheduled.json"),
|
||
},
|
||
"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": "",
|
||
},
|
||
"schedule": {
|
||
# Slingringsmonn etter planlagt tid. Tråden våkner med noen sekunders
|
||
# mellomrom, og en kjapp omstart skal ikke miste en sending. Mer enn
|
||
# dette for sent regnes som gått glipp av, og sendes ikke – et utrop
|
||
# om noe som skjedde for en time siden er feil å spille.
|
||
"grace_seconds": 120,
|
||
"max_days_ahead": 30,
|
||
# Hvor ofte vi ser etter forfalte sendinger.
|
||
"tick_seconds": 20,
|
||
},
|
||
"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", "schedule"],
|
||
"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_SCHEDULE": (("paths", "schedule"), 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)
|