diff --git a/.gitignore b/.gitignore index 2a6e7cb..f2d22f9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,10 @@ cache/ voices/*.onnx* __pycache__/ +config.toml +logs/ +instance/ +.pytest_cache/ +.docker-data/ +# onnxruntime slenger fra seg denne når telemetri-ID ikke kan lagres +:memory:.ses diff --git a/AGENTS.md b/AGENTS.md index afb4c95..5a66ce4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,39 +1,165 @@ -# sg-announcer – utrop på høyttaleranlegget (Sauda Gathering) +# announcer – utrop på høyttaleranlegget -Liten Flask-app: tekst inn i nettleseren → forhåndslytt → «Send ut på -anlegget» spiller lyden lokalt på **denne** maskinen med `pw-play`. TTS er -Piper (`piper-tts`, onnx, helt lokalt). Alt ligger i to filer: -`announcer.py` (server + kø) og `index.html` (sida). +Liten Flask-app: tekst inn i nettleseren → forhåndslytt → «Send ut på anlegget» +spiller lyden lokalt på **denne** maskinen med `pw-play`. TTS er edge-tts +(nevrale stemmer, krever nett) med Piper som lokal reserve. -Dokumentasjon for Kent: Athena-notatet -`30 Ressurser/Teknologi/Sauda Gathering - announcer.md`. Oppdater det, ikke -denne fila, når noe som er verdt å huske endres. +Appen er generisk: tittel, undertittel, aksentfarge og hurtigfraser kommer fra +`config.toml`, ikke fra HTML-en. Den het `sg-announcer` og var skrevet for Sauda +Gathering fram til oktober 2026. + +Dokumentasjon for Kent: Athena-notatet `30 Ressurser/Teknologi/Announcer.md`. +Oppdater det, ikke denne fila, når noe som er verdt å huske endres. + +Filer: + +| Fil | Hva | +|---|---| +| `announcer.py` | server, kø, TTS, jingler, ruter | +| `auth.py` | `Principal`, leverandørene, OIDC-rutene | +| `config.py` | `config.toml` + miljøvariabler, standardverdier | +| `index.html` | hele sida, inline CSS/JS, ingen byggesteg | +| `config.example.toml` | kommentert mal; kopier til `config.toml` | +| `docs/crew-integration.md` | kontrakten mot crew-panelet | +| `tests/test_auth.py` | `uv run pytest` | +| `compose.yaml` + `Dockerfile` | testoppsett, se under. Ikke produksjon | +| `docker/dummy_idp.py` | falsk crew-panel, så OIDC kan prøves | ## Kjøring -Tjenesten er en systemd-brukerenhet: `~/.config/systemd/user/sg-announcer.service` -(enabled, port 8090). `systemctl --user restart sg-announcer` etter endringer -i koden. Manuelt: `uv run announcer.py --port 8090`. +Tjenesten er en systemd-brukerenhet: `~/.config/systemd/user/announcer.service` +(enabled, port 8090). `systemctl --user restart announcer` etter endringer i +koden. Manuelt: `uv run announcer.py --port 8090`. -Ny maskin (Debian/Ubuntu/Mint): `git clone https://git.1x1.no/kent/sg-announcer.git` +Ny maskin (Debian/Ubuntu/Mint): `git clone https://git.1x1.no/kent/announcer.git` og `./install.sh` gjør alt (apt, uv, Python 3.13, stemmer, enhet, ufw). Finnes -NetBird (`wt0`) skrur det på ufw med deny incoming og åpner 22 og porten kun -på `wt0`. Kan -kjøres på nytt. Avspilling bruker `pw-play` når PipeWire kjører, ellers -`paplay` (`player_cmd()`), så Mint 21 med PulseAudio virker også. +NetBird (`wt0`) skrur det på ufw med deny incoming og åpner 22, 22022 og porten +kun på `wt0`. Kan kjøres på nytt, og rydder da bort en gammel +`sg-announcer.service` hvis den ligger der. Avspilling bruker `pw-play` når +PipeWire kjører, ellers `paplay` (`player_cmd()`), så Mint 21 med PulseAudio +virker også. -Miljøvariabler (settes i enheten): `ANNOUNCER_PORT`, `ANNOUNCER_PIN` (tom = -ingen PIN), `ANNOUNCER_SINK` (pw-play `--target`; tom = standard utgang). +### Omdøpingen: ett punkt gjenstår + +Mappa er flyttet til `~/Work/announcer`, enheten heter `announcer.service`, og +remote peker på `https://git.1x1.no/kent/announcer.git`. **Men repoet i Forgejo +heter `annuoncer`** – en skrivefeil. Rett navnet i Forgejo, så virker remote; +Forgejo legger selv inn en omdirigering fra det gamle navnet. + +Flytter du mappa igjen: `.venv` har absolutte stier i `pyvenv.cfg` og i +shebangen til hvert konsollskript, så `uv sync` alene er ikke nok. Da må +`rm -rf .venv && uv sync` til, og tjenesten startes om. + + +## Testing i Docker + +`docker compose up --build` gir to containere: + +| | | +|---|---| +| `http://localhost:8091` | announcer med PIN, JWT **og** OIDC slått på (`docker/config.test.toml`) | +| `http://localhost:9000` | `docker/dummy_idp.py`, som later som den er crew-panelet | + +Port 8091 med vilje, så den ikke kolliderer med tjenesten på 8090. + +Dummy-IdP-en finnes fordi crew-panelet ikke har OIDC ennå: uten den kan +OIDC-veien ikke prøves i det hele tatt. Den kjører en ekte +autorisasjonskode-runde med PKCE (og avviser feil `code_verifier`), signerer +`id_token` med klienthemmeligheten, og lar deg velge hvem du logger inn som – +Kari (`crew`), Kent (`admin`) eller Ola (ingen roller, skal gi 403, ikke 401). +`http://localhost:9000/token?who=kari&go=1` logger deg rett inn via +token-lenka, slik panelet skal gjøre. + +Merk i `docker/config.test.toml`: `authorization_endpoint` peker på +`localhost:9000` fordi nettleseren skal dit, mens `token_endpoint` peker på +`idp:9000` fordi announcer selv snakker med den inne i compose-nettet. Delt +horisont, og samtidig en test av at manuelle endepunkter virker når et panel +mangler discovery. + +Lyd: `/run/user/1000` er montert inn, så `pw-play` i containeren spiller på +vertens anlegg. Kommenter ut den linja i `compose.yaml` hvis du bare skal +teste innlogging. + +`UV_PROJECT_ENVIRONMENT=/opt/venv` i bildet er ikke pynt: `/app` er et +bind-mount av repoet, og uten det ville containerens `uv sync` overskrevet +`.venv` som den kjørende tjenesten bruker. + +## Konfigurasjon + +Tre lag, siste vinner: `DEFAULTS` i `config.py` → `config.toml` → +`ANNOUNCER_*`-miljøvariabler. **Uten `config.toml` virker alt**, med de gamle +standardverdiene og uten innlogging; det er med vilje, og +`test_uten_konfig_er_alt_apent` passer på det. + +`config.toml` er gitignorert. `config.example.toml` er malen, og den er +kommentert – les den framfor å gjette. + +Hemmeligheter settes som miljøvariabler i enheten, ikke i `config.toml`: +`ANNOUNCER_PIN`, `ANNOUNCER_JWT_SECRET`, `ANNOUNCER_OIDC_CLIENT_SECRET`, +`ANNOUNCER_SECRET_KEY`. Hele tabellen over variabler står i `ENV` i `config.py`; +den er eneste sted en ny variabel legges til. + +## Autentisering + +Modellen er én datatype og én funksjon: `Principal` (hvem + roller + +rettigheter) og `require(action)` som enten gir deg den eller stopper +forespørselen. Handlingene er `view`, `preview`, `announce`, `stop`, `clear`. + +Tre leverandører, prøvd i rekkefølge, alle valgfrie: + +- **pin** – `X-Pin`-header, som før. `hmac.compare_digest`, ikke `==`. + Får rollen `admin` som standard, så gammelt oppsett oppfører seg som før. + Virker uten nett, derfor beholdt. +- **jwt** – `Authorization: Bearer ` fra crew-panelet. HS256 med delt + hemmelighet, eller RS256/ES256 mot panelets JWKS (PyJWT cacher nøklene). + Krever `exp`; roller leses av et konfigurerbart claim. +- **oidc** – autorisasjonskode med PKCE. `/auth/login` → panelet → + `/auth/callback`. Discovery hentes først ved første innlogging, så tjenesten + starter selv om panelet er nede. + +**En leverandør er aktiv når den står i `auth.providers` *eller* når den er +ferdig konfigurert.** Derfor slår `ANNOUNCER_PIN=1234` alene fortsatt på PIN. +Er ingen aktiv, er alt åpent. + +`/auth/token?t=` er broa mens panelet mangler OIDC: panelet lenker hit med +et kortlevd token, announcer setter sesjonscookie. Se +`docs/crew-integration.md` – det er kontrakten panelet skal oppfylle. + +Roller → handlinger står i `[roles]` i `config.toml`. `"*"` betyr alt. +Rettighetene slås opp på nytt per forespørsel, så en endring der slår inn uten +at noen må logge inn igjen; nye `[auth.*]`-verdier krever omstart. + +401 betyr «vi vet ikke hvem du er, logg inn», 403 betyr «vi vet hvem du er, men +rollen din får ikke dette». Et gyldig token uten roller gir 403, ikke 401 – å +logge inn på nytt ville ikke hjulpet. + +Sesjonsnøkkelen genereres én gang og lagres i `instance/secret_key` (0600). +Uten den logges alle ut ved hver `systemctl restart`. `SESSION_COOKIE_SECURE` +settes bare når `server.behind_tls` er sann – appen kjører på rå HTTP over LAN +og NetBird, og med Secure på ville cookien aldri blitt sendt. ## Hvordan det henger sammen +- `/api/config` er alt sida trenger for å tegne seg selv: branding, stemmer, + jingler og en `auth`-blokk med hvem du er og hva du får gjøre. Svarer alltid + 200, også med feil PIN – klienten spør «hvem er jeg?», og «ikke innlogget» er + et svar, ikke en feil. Route-funksjonen heter `client_config()`; navnet + `config` er tatt av modulen. - `/api/preview` syntetiserer og returnerer WAV til nettleseren. Ingen lyd på PC-en. -- `/api/announce` legger meldingen i én FIFO-kø; en bakgrunnstråd spiller én - om gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear` +- `/api/announce` legger meldingen i én FIFO-kø; en bakgrunnstråd spiller én om + gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear` tømmer køen. +- `/api/status` krever `view`. Den var åpen før, og lekket både meldingstekster + og IP-ene til dem som hadde sendt noe. IP vises nå bare for den som har + `clear`; alle andre ser navnet. +- `/` er åpen uansett, ellers kunne ingen komme til innloggingsknappen. - Knappen «Send ut» er låst til nøyaktig den teksten/stemmen/hastigheten som sist ble forhåndslyttet. Det er med vilje: ingen skal sende noe de ikke har hørt. - Ferdige WAV-er bufres i `cache/` etter innholdshash. Trygt å slette. +- `audit()` skriver én JSON-linje per hendelse til `logs/announcements.jsonl` + (`announce`, `played`, `stop`, `clear`) med navn, subject, metode og IP. + Rullerer til `.jsonl.1` ved 5 MB. Den feiler aldri forespørselen – en logg + som ikke lar seg skrive skal ikke hindre et utrop. - Jingler: de innebygde (`BUILTIN_CHIMES`) genereres i Python med `_tone`/ `_mix`/`_overlay`, ingen lydfiler. Egne filer i `chimes/` (wav/mp3/ogg/flac) dukker opp i menyen som `file:`; ffmpeg kutter til 6 s, normaliserer @@ -65,6 +191,17 @@ Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI åpnes eksplisitt for LAN-et (`sudo ufw allow from 10.20.70.0/23 to any port 8090 proto tcp`). - `pkill -f announcer.py` fra et skall som selv har `announcer.py` i kommandolinja dreper skallet. Bruk `systemctl --user`. +- `player = Player()` står på modulnivå, så avspillingstråden starter ved + `import announcer` – også i `install.sh`, som importerer modulen for å laste + ned stemmer. Harmløst, men greit å vite. +- `tests/conftest.py` nuller ut alle `ANNOUNCER_*`-variabler før hver test. + Uten den arver testene maskinens miljø – systemd-enheten setter + `ANNOUNCER_PORT`, og compose setter JWT- og OIDC-hemmeligheter, noe som + gjorde 28 tester røde i containeren med feilmeldinger som ikke pekte på noe + galt. +- `pyproject.toml` har `[tool.uv] package = false`. Appen er løse `.py`-filer + som kjøres på plass, ikke en pakke; uten den vil `uv sync` bygge et wheel og + klage på at `announcer/__init__.py` mangler. - Piper 1.8 bruker `SynthesisConfig(length_scale=…)`; lavere = raskere. - Forhåndslytt svarer 502 med feilmelding hvis edge-tts feiler (ingen nett). Køen markerer meldingen som `failed`. Bytt til en lokal reserve-stemme. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd40177 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Testbilde for announcer. Ikke ment for produksjon – på en ekte maskin +# kjører appen nativt med install.sh, fordi lyden skal ut på maskinens egen +# lydutgang og det er vondt å få til gjennom et containerlag. +# +# Her er poenget å kunne prøve innlogging (PIN, JWT, OIDC) uten å røre +# tjenesten som kjører, og uten sudo eller brannmurendringer. +FROM python:3.13-slim + +# ffmpeg trengs av edge_pcm() og av egne jinglefiler. pw-play/paplay er bare +# nyttig hvis vertens PipeWire-socket monteres inn; uten den feiler selve +# avspillingen, men resten av appen virker. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \ + && (apt-get install -y --no-install-recommends pipewire-bin pulseaudio-utils || true) \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Venv utenfor /app: /app er et bind-mount av repoet, og en .venv bygget her +# ville ellers overskrevet den verten bruker til den kjørende tjenesten. +ENV UV_PROJECT_ENVIRONMENT=/opt/venv \ + UV_COMPILE_BYTECODE=1 \ + PATH=/opt/venv/bin:$PATH \ + PYTHONUNBUFFERED=1 + +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen + +EXPOSE 8090 +CMD ["python", "announcer.py", "--host", "0.0.0.0", "--port", "8090"] diff --git a/announcer.py b/announcer.py index 46fe322..19f37c2 100644 --- a/announcer.py +++ b/announcer.py @@ -1,16 +1,19 @@ #!/usr/bin/env python3 -"""SG Announcer – tekst inn, forhåndslytt i nettleseren, les ut på PC-ens lydutgang. +"""Announcer – tekst inn, forhåndslytt i nettleseren, les ut på PC-ens lydutgang. -Kjøres på PC-en som er koblet til høyttaleranlegget. Alle på nettet kan åpne -sida og sende meldinger; selve avspillingen skjer alltid lokalt på denne -maskinen via pw-play (PipeWire). +Kjøres på PC-en som er koblet til høyttaleranlegget. Selve avspillingen skjer +alltid lokalt på denne maskinen via pw-play (PipeWire) eller paplay. + +Tittel, tekst og hurtigfraser kommer fra config.py, så appen ikke er bundet til +ett arrangement. Innlogging er valgfri og settes opp i auth.py; uten +konfigurasjon er alt åpent, som før. """ from __future__ import annotations import argparse import hashlib -import io +import json import logging import math import os @@ -26,12 +29,17 @@ from pathlib import Path from flask import Flask, Response, abort, jsonify, request, send_file +import auth +import config +from auth import require + HERE = Path(__file__).resolve().parent -VOICE_DIR = Path(os.environ.get("ANNOUNCER_VOICES", HERE / "voices")) -CACHE_DIR = Path(os.environ.get("ANNOUNCER_CACHE", HERE / "cache")) -PIN = os.environ.get("ANNOUNCER_PIN", "").strip() -SINK = os.environ.get("ANNOUNCER_SINK", "").strip() # pw-play --target -MAX_CHARS = 600 +CFG = config.load() +VOICE_DIR = CFG.path("voices") +CACHE_DIR = CFG.path("cache") +LOG_PATH = CFG.path("log") +SINK = str(CFG.get("audio", "sink", "")).strip() # pw-play --target +MAX_CHARS = int(CFG.app["max_chars"]) # Stemmene som tilbys i menyen. To motorer: # edge – Microsofts nevrale stemmer via edge-tts (gratis, krever internett, @@ -65,11 +73,16 @@ VOICES_LIST: list[dict] = [ _piper("en-jenny", "Jenny (UK)", "Lokal reserve (uten nett)", "en_GB-jenny_dioco-medium"), ] VOICES: dict[str, dict] = {v["id"]: v for v in VOICES_LIST} -DEFAULT_VOICE = "finn" EDGE_RATE = 24000 +# Konfigurerte standardvalg, men bare hvis de finnes – en skrivefeil i +# config.toml skal ikke gjøre sida ubrukelig. +DEFAULT_VOICE = CFG.app["default_voice"] if CFG.app["default_voice"] in VOICES else "finn" +DEFAULT_CHIME = CFG.app["default_chime"] + log = logging.getLogger("announcer") app = Flask(__name__, static_folder=None) +AUTH = auth.install(app, CFG) # -------------------------------------------------------------------------- @@ -104,7 +117,7 @@ def load_voice(name: str): # (wav/mp3/ogg/flac) i CHIME_DIR dukker opp automatisk i menyen. # -------------------------------------------------------------------------- -CHIME_DIR = Path(os.environ.get("ANNOUNCER_CHIMES", HERE / "chimes")) +CHIME_DIR = CFG.path("chimes") def _tone(rate: int, freq: float, dur: float, vol: float = 0.35, wave_: str = "sine", @@ -305,17 +318,23 @@ class Item: voice: str speed: float chime: str - sender: str + sender: str # IP-adressen forespørselen kom fra + user: str = "" # visningsnavnet til den som sendte + subject: str = "" # stabil id, f.eks. "jwt:kent" + method: str = "none" created: float = field(default_factory=time.time) status: str = "queued" # queued | playing | done | stopped | failed error: str = "" - def public(self) -> dict: + def public(self, *, show_ip: bool = False) -> dict: return { "id": self.id, "text": self.text, "voice": VOICES[self.voice]["name"] if self.voice in VOICES else self.voice, - "sender": self.sender, + "user": self.user, + # IP-en er bare interessant for en admin som skal finne ut hvem + # som rotet; alle andre ser navnet. + "sender": self.sender if show_ip else "", "created": self.created, "status": self.status, "error": self.error, @@ -348,10 +367,16 @@ class Player: self.seq = 0 threading.Thread(target=self._loop, daemon=True, name="player").start() - def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str) -> Item: + def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str, + who: auth.Principal | None = None) -> Item: with self.lock: self.seq += 1 - item = Item(self.seq, text, voice, speed, chime, sender) + item = Item( + self.seq, text, voice, speed, chime, sender, + user=who.name if who else "", + subject=who.subject if who else "", + method=who.method if who else "none", + ) self.history.append(item) del self.history[:-50] self.q.put(item) @@ -373,12 +398,13 @@ class Player: item.status = "stopped" self.stop() - def snapshot(self) -> dict: + def snapshot(self, *, show_ip: bool = False) -> dict: with self.lock: return { - "current": self.current.public() if self.current else None, - "queued": [i.public() for i in self.history if i.status == "queued"], - "history": [i.public() for i in reversed(self.history) if i.status not in ("queued", "playing")][:15], + "current": self.current.public(show_ip=show_ip) if self.current else None, + "queued": [i.public(show_ip=show_ip) for i in self.history if i.status == "queued"], + "history": [i.public(show_ip=show_ip) for i in reversed(self.history) + if i.status not in ("queued", "playing")][:15], } def _loop(self) -> None: @@ -409,6 +435,9 @@ class Player: with self.lock: self.current = None self.proc = None + audit("played", id=item.id, status=item.status, error=item.error, + subject=item.subject, name=item.user, method=item.method, + ip=item.sender, text=item.text, voice=item.voice) player = Player() @@ -419,9 +448,34 @@ player = Player() # -------------------------------------------------------------------------- -def check_pin() -> None: - if PIN and request.headers.get("X-Pin", "") != PIN: - abort(401) +MAX_LOG_BYTES = 5_000_000 +_log_lock = threading.Lock() + + +def audit(action: str, **fields) -> None: + """Én JSON-linje per hendelse, så man i ettertid kan se hvem som sendte hva. + + Append-only og rullerer ved 5 MB. Feiler aldri forespørselen – en logg som + ikke lar seg skrive skal ikke hindre et utrop. + """ + rec = {"ts": round(time.time(), 3), "action": action, + **{k: v for k, v in fields.items() if v not in ("", None)}} + line = json.dumps(rec, ensure_ascii=False) + try: + with _log_lock: + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + if LOG_PATH.exists() and LOG_PATH.stat().st_size > MAX_LOG_BYTES: + LOG_PATH.replace(LOG_PATH.with_name(LOG_PATH.name + ".1")) + with LOG_PATH.open("a", encoding="utf-8") as fh: + fh.write(line + "\n") + except OSError as e: + log.warning("fikk ikke skrevet til %s: %s", LOG_PATH, e) + + +def who(p: auth.Principal) -> dict: + """Identitetsfeltene audit() vil ha.""" + return {"subject": p.subject, "name": p.name, "method": p.method, + "ip": request.remote_addr or ""} def parse_request() -> tuple[str, str, float, str]: @@ -439,9 +493,9 @@ def parse_request() -> tuple[str, str, float, str]: except (TypeError, ValueError): abort(400, "Ugyldig hastighet") speed = max(0.6, min(1.6, speed)) - chime = data.get("chime", "dingdong") + chime = data.get("chime", DEFAULT_CHIME) if chime is True: - chime = "dingdong" + chime = DEFAULT_CHIME elif chime is False or chime is None: chime = "none" chime = str(chime) @@ -456,58 +510,86 @@ def index() -> Response: @app.get("/api/config") -def config() -> Response: +def client_config() -> Response: + """Alt sida trenger for å tegne seg selv: branding, menyer og hvem du er. + + Svarer alltid 200, også med feil PIN – her spør klienten «hvem er jeg?», + og da er svaret «ikke innlogget», ikke en feil. + """ + me = auth.current() return jsonify( { + "app": { + "title": CFG.app["title"], + "subtitle": CFG.app["subtitle"], + "accent": CFG.app["accent"], + "idle_text": CFG.app["idle_text"], + "message_placeholder": CFG.app["message_placeholder"], + "quick_phrases": list(CFG.app["quick_phrases"]), + }, "voices": [{"id": v["id"], "name": v["name"], "group": v["group"], "engine": v["engine"]} for v in VOICES_LIST], "default_voice": DEFAULT_VOICE, "chimes": list_chimes(), - "default_chime": "dingdong", + "default_chime": DEFAULT_CHIME, "max_chars": MAX_CHARS, - "pin_required": bool(PIN), + "auth": { + **AUTH.describe(), + "user": None if me.anonymous else me.public(), + "can": sorted(me.permissions), + "error": auth.error(), + }, } ) @app.post("/api/preview") def preview() -> Response: - check_pin() + me = require("preview") text, voice, speed, chime = parse_request() try: path = synthesize(text, voice, speed, chime) except Exception as e: # noqa: BLE001 log.exception("syntese feilet") + audit("preview", **who(me), text=text, voice=voice, error=str(e)[:200]) abort(502, f"Syntese feilet ({VOICES[voice]['name']}): {e}. Prøv en lokal reserve-stemme.") + audit("preview", **who(me), text=text, voice=voice) return send_file(path, mimetype="audio/wav", max_age=3600) @app.post("/api/announce") def announce() -> Response: - check_pin() + me = require("announce") text, voice, speed, chime = parse_request() sender = request.remote_addr or "?" - item = player.enqueue(text, voice, speed, chime, sender) - log.info("kø #%d fra %s: %s", item.id, sender, text) - return jsonify(item.public()) + item = player.enqueue(text, voice, speed, chime, sender, me) + log.info("kø #%d fra %s (%s): %s", item.id, item.user or sender, sender, text) + audit("announce", **who(me), id=item.id, text=text, voice=voice, + speed=speed, chime=chime) + return jsonify(item.public(show_ip=me.can("clear"))) @app.post("/api/stop") def stop() -> Response: - check_pin() + me = require("stop") player.stop() + audit("stop", **who(me)) return jsonify({"ok": True}) @app.post("/api/clear") def clear() -> Response: - check_pin() + me = require("clear") player.clear() + audit("clear", **who(me)) return jsonify({"ok": True}) @app.get("/api/status") def status() -> Response: - return jsonify(player.snapshot()) + # Lukket bak «view»: køen inneholder meldingstekstene og IP-ene til dem + # som har sendt noe, og det er ikke noe alle utenfra skal få se. + me = require("view") + return jsonify(player.snapshot(show_ip=me.can("clear"))) @app.errorhandler(400) @@ -522,19 +604,40 @@ def bad_gateway(e): # noqa: ANN001 @app.errorhandler(401) def unauthorized(e): # noqa: ANN001 - return jsonify({"error": "Feil PIN"}), 401 + return jsonify({"error": e.description, "login": True}), 401 + + +@app.errorhandler(403) +def forbidden(e): # noqa: ANN001 + return jsonify({"error": e.description}), 403 + + +@app.errorhandler(404) +def not_found(e): # noqa: ANN001 + # Klienten snakker JSON med /api og /auth, og skal ikke få en HTML-side + # tilbake og tolke den som en feilmelding. + if request.path.startswith(("/api/", "/auth/")): + msg = e.description or "" + if not msg or "requested URL" in msg: + msg = "Finnes ikke" + return jsonify({"error": msg}), 404 + return e def main() -> None: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - ap.add_argument("--host", default="0.0.0.0") - ap.add_argument("--port", type=int, default=int(os.environ.get("ANNOUNCER_PORT", 8090))) + ap.add_argument("--host", default=CFG.get("server", "host", "0.0.0.0")) + ap.add_argument("--port", type=int, default=int(CFG.get("server", "port", 8090))) args = ap.parse_args() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.getLogger("werkzeug").setLevel(logging.WARNING) ensure_voices() load_voice("no_NO-talesyntese-medium") # varm opp reserven - log.info("klar på http://%s:%d (PIN: %s, sink: %s)", args.host, args.port, "på" if PIN else "av", SINK or "standard") + if CFG.source: + log.info("konfigurasjon fra %s", CFG.source) + methods = ", ".join(m["method"] for m in AUTH.describe()["methods"]) or "åpen (ingen innlogging)" + log.info("klar på http://%s:%d (auth: %s, sink: %s)", + args.host, args.port, methods, SINK or "standard") app.run(host=args.host, port=args.port, threaded=True) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..11975f6 --- /dev/null +++ b/auth.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +"""Pluggbar autentisering for announcer. + +Hele modellen er én datatype og én funksjon: + + Principal – hvem dette er, hvilke roller, hva rollene får gjøre + require(action) – hent den, eller stopp forespørselen med 401/403 + +Rundt det ligger leverandører som prøves i rekkefølge. Hver av dem svarer på +én forespørsel med «dette er brukeren», «jeg fant ingen legitimasjon av min +type» (None) eller AuthError («fant noe, men det var ugyldig»): + + pin – X-Pin-header, én delt kode. Virker uten nett, derfor beholdt. + jwt – Authorization: Bearer fra crew-panelet. HS256 med delt + hemmelighet, eller RS256/ES256 mot panelets JWKS. + oidc – autorisasjonskode med PKCE mot crew-panelet; nettleseren logger + inn der og får en sesjonscookie her. + +Er ingen leverandør konfigurert, er alt åpent – nøyaktig som før. Det er med +vilje: tjenesten skal kunne settes opp på en kveld uten identitetsleverandør. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import logging +import json +import secrets +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from flask import Blueprint, abort, g, redirect, request, session, url_for + +from config import ACTIONS, Config + +log = logging.getLogger("announcer.auth") + +SESSION_KEY = "principal" +NAME_CLAIMS = ("name", "preferred_username", "nickname", "given_name", "email") + + +class AuthError(Exception): + """Legitimasjon fulgte med, men den holdt ikke.""" + + +# -------------------------------------------------------------------------- +# Principal +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Principal: + subject: str # stabil id: "pin", "jwt:4f2a", "oidc:kent" + name: str # vises i køen og i loggen + roles: tuple[str, ...] + method: str # none | anonymous | pin | jwt | oidc + permissions: frozenset[str] + + @property + def anonymous(self) -> bool: + return self.method in ("none", "anonymous") + + def can(self, action: str) -> bool: + return action in self.permissions + + def public(self) -> dict[str, Any]: + return { + "subject": self.subject, + "name": self.name, + "roles": list(self.roles), + "method": self.method, + "can": sorted(self.permissions), + } + + +def _claim(claims: dict, path: str) -> Any: + """Henter et claim, også nestet: "realm_access.roles".""" + cur: Any = claims + for part in path.split("."): + if not isinstance(cur, dict): + return None + cur = cur.get(part) + return cur + + +def _roles(value: Any) -> tuple[str, ...]: + """Roller kommer som liste, streng eller mellomromsdelt streng.""" + if isinstance(value, str): + return tuple(v for v in value.replace(",", " ").split() if v) + if isinstance(value, (list, tuple, set)): + return tuple(str(v).strip() for v in value if str(v).strip()) + return () + + +def _display_name(claims: dict, name_claim: str) -> str: + if name_claim: + value = _claim(claims, name_claim) + if value: + return str(value) + for key in NAME_CLAIMS: + if claims.get(key): + return str(claims[key]) + return str(claims.get("sub") or "ukjent") + + +# -------------------------------------------------------------------------- +# JWT-verifisering, delt av jwt- og oidc-leverandøren +# -------------------------------------------------------------------------- + + +class TokenVerifier: + """Verifiserer signatur, iss, aud og exp. JWKS caches av PyJWT selv.""" + + def __init__(self, section: dict[str, Any]) -> None: + self.issuer = str(section.get("issuer", "")).strip() + self.audience = str(section.get("audience", "")).strip() + self.secret = str(section.get("secret", "")).strip() + self.jwks_url = str(section.get("jwks_url", "")).strip() + self.leeway = int(section.get("leeway", 30)) + self.verify_signature = bool(section.get("verify_signature", True)) + # Tom liste: utled av hvordan nøkkelen kommer. Delt hemmelighet er + # symmetrisk (HS256), JWKS er asymmetrisk (RS256/ES256). + algs = [str(a) for a in section.get("algorithms") or []] + self.algorithms = algs or (["RS256", "ES256"] if self.jwks_url else ["HS256"]) + self._jwks: Any = None + + def _signing_key(self, token: str) -> Any: + if not self.jwks_url: + return self.secret + if self._jwks is None: + import jwt # lazy: PIN-oppsett skal ikke trenge pyjwt + + self._jwks = jwt.PyJWKClient(self.jwks_url, cache_keys=True, lifespan=3600) + return self._jwks.get_signing_key_from_jwt(token).key + + def verify(self, token: str) -> dict: + import jwt + + if not self.verify_signature: + # Bare for et crew-panel bak en proxy som alt har validert tokenet. + return jwt.decode(token, options={"verify_signature": False}) + if not (self.secret or self.jwks_url): + raise AuthError("JWT er ikke konfigurert") + try: + return jwt.decode( + token, + self._signing_key(token), + algorithms=self.algorithms, + audience=self.audience or None, + issuer=self.issuer or None, + leeway=self.leeway, + options={ + "verify_aud": bool(self.audience), + "verify_iss": bool(self.issuer), + "require": ["exp"], + }, + ) + except jwt.ExpiredSignatureError as e: + raise AuthError("Tokenet er utløpt") from e + except jwt.InvalidAudienceError as e: + raise AuthError("Tokenet gjelder en annen tjeneste") from e + except jwt.InvalidTokenError as e: + raise AuthError(f"Ugyldig token ({e})") from e + except urllib.error.URLError as e: + raise AuthError(f"Fikk ikke hentet nøkler fra {self.jwks_url} ({e})") from e + + +# -------------------------------------------------------------------------- +# Leverandører +# -------------------------------------------------------------------------- + + +class Provider: + name = "" + + def authenticate(self, req) -> Principal | None: # noqa: ANN001 + return None + + def describe(self) -> dict[str, Any]: + return {"method": self.name} + + +class PinProvider(Provider): + name = "pin" + + def __init__(self, cfg: Config) -> None: + s = cfg.section("auth.pin") + self.pin = str(s.get("pin", "")).strip() + self.role = str(s.get("role") or "admin") + self.label = str(s.get("label") or "PIN") + self.hint = str(s.get("hint", "")) + self.cfg = cfg + + def authenticate(self, req) -> Principal | None: # noqa: ANN001 + given = req.headers.get("X-Pin", "").strip() + if not given: + auth = req.headers.get("Authorization", "") + if auth[:4].lower() == "pin ": + given = auth[4:].strip() + if not given: + return None + # compare_digest, ikke ==, så svartiden ikke lekker hvor langt man kom. + if not hmac.compare_digest(given, self.pin): + raise AuthError("Feil PIN") + roles = (self.role,) + # En delt kode peker ikke på en person, og skal ikke late som. Vil man + # vite hvem som sendte hva, er det JWT eller OIDC man vil ha. + return Principal("pin", "delt PIN", roles, "pin", self.cfg.permissions(roles)) + + def describe(self) -> dict[str, Any]: + return {"method": "pin", "label": self.label, "hint": self.hint} + + +class JwtProvider(Provider): + name = "jwt" + + def __init__(self, cfg: Config) -> None: + s = cfg.section("auth.jwt") + self.cfg = cfg + self.verifier = TokenVerifier(s) + self.role_claim = str(s.get("role_claim") or "roles") + self.name_claim = str(s.get("name_claim", "")) + self.default_role = str(s.get("default_role", "")) + self.session_seconds = int(s.get("session_seconds", 43200)) + + def principal(self, claims: dict, method: str = "jwt") -> Principal: + roles = _roles(_claim(claims, self.role_claim)) + if not roles and self.default_role: + roles = (self.default_role,) + sub = str(claims.get("sub") or claims.get("email") or "ukjent") + return Principal( + f"{method}:{sub}", + _display_name(claims, self.name_claim), + roles, + method, + self.cfg.permissions(roles), + ) + + def authenticate(self, req) -> Principal | None: # noqa: ANN001 + auth = req.headers.get("Authorization", "") + if auth[:7].lower() != "bearer ": + return None + token = auth[7:].strip() + if not token: + return None + return self.principal(self.verifier.verify(token)) + + +class OidcProvider(Provider): + """Autorisasjonskode med PKCE. Innloggingen skjer i nettleseren, så + authenticate() finner aldri noe i en header – sesjonen tar seg av resten.""" + + name = "oidc" + + def __init__(self, cfg: Config) -> None: + s = cfg.section("auth.oidc") + self.cfg = cfg + self.issuer = str(s.get("issuer", "")).strip().rstrip("/") + self.client_id = str(s.get("client_id", "")).strip() + self.client_secret = str(s.get("client_secret", "")).strip() + self.scopes = [str(x) for x in s.get("scopes") or ["openid"]] + self.label = str(s.get("label") or "Logg inn") + self.role_claim = str(s.get("role_claim") or "roles") + self.name_claim = str(s.get("name_claim", "")) + self.default_role = str(s.get("default_role", "")) + self._manual = { + "authorization_endpoint": str(s.get("authorization_endpoint", "")).strip(), + "token_endpoint": str(s.get("token_endpoint", "")).strip(), + "jwks_uri": str(s.get("jwks_url", "")).strip(), + "end_session_endpoint": str(s.get("end_session_endpoint", "")).strip(), + } + self._meta: dict[str, Any] | None = None + self._verifier: TokenVerifier | None = None + + # -- oppdagelse ------------------------------------------------------- + def metadata(self) -> dict[str, Any]: + """Discovery hentes først ved første innlogging, aldri ved oppstart – + tjenesten skal starte selv om crew-panelet er nede.""" + if self._meta is not None: + return self._meta + meta = {k: v for k, v in self._manual.items() if v} + if not (meta.get("authorization_endpoint") and meta.get("token_endpoint")): + url = f"{self.issuer}/.well-known/openid-configuration" + try: + fetched = _get_json(url) + except Exception as e: # noqa: BLE001 + raise AuthError(f"Fikk ikke hentet OIDC-oppsett fra {url} ({e})") from e + meta = {**fetched, **meta} + self._meta = meta + return meta + + def verifier(self) -> TokenVerifier: + if self._verifier is None: + meta = self.metadata() + self._verifier = TokenVerifier( + { + "issuer": meta.get("issuer") or self.issuer, + "audience": self.client_id, + "secret": self.client_secret, + "jwks_url": meta.get("jwks_uri", ""), + "algorithms": [], + "verify_signature": bool( + self.cfg.get("auth.oidc", "verify_signature", True) + ), + } + ) + return self._verifier + + # -- flyten ----------------------------------------------------------- + def redirect_uri(self) -> str: + base = str(self.cfg.get("server", "public_url", "")).strip().rstrip("/") + if base: + return f"{base}/auth/callback" + return url_for("auth.callback", _external=True) + + def authorize_url(self) -> str: + verifier = secrets.token_urlsafe(64) + state = secrets.token_urlsafe(24) + challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) + .rstrip(b"=") + .decode() + ) + session["oidc_state"] = state + session["oidc_verifier"] = verifier + params = { + "response_type": "code", + "client_id": self.client_id, + "redirect_uri": self.redirect_uri(), + "scope": " ".join(self.scopes), + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + } + endpoint = self.metadata()["authorization_endpoint"] + sep = "&" if "?" in endpoint else "?" + return f"{endpoint}{sep}{urllib.parse.urlencode(params)}" + + def exchange(self, code: str, verifier: str) -> dict: + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.redirect_uri(), + "client_id": self.client_id, + "code_verifier": verifier, + } + headers = {} + if self.client_secret: + basic = base64.b64encode( + f"{self.client_id}:{self.client_secret}".encode() + ).decode() + headers["Authorization"] = f"Basic {basic}" + try: + return _post_json(self.metadata()["token_endpoint"], data, headers) + except Exception as e: # noqa: BLE001 + raise AuthError(f"Kodebyttet feilet ({e})") from e + + def principal(self, claims: dict) -> Principal: + roles = _roles(_claim(claims, self.role_claim)) + if not roles and self.default_role: + roles = (self.default_role,) + sub = str(claims.get("sub") or claims.get("email") or "ukjent") + return Principal( + f"oidc:{sub}", + _display_name(claims, self.name_claim), + roles, + "oidc", + self.cfg.permissions(roles), + ) + + def describe(self) -> dict[str, Any]: + return {"method": "oidc", "label": self.label, "login_url": "/auth/login"} + + +PROVIDERS = {"pin": PinProvider, "jwt": JwtProvider, "oidc": OidcProvider} + + +# -------------------------------------------------------------------------- +# HTTP-hjelpere (stdlib, så vi slipper en ekstra avhengighet) +# -------------------------------------------------------------------------- + + +def _get_json(url: str, timeout: int = 10) -> dict: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 + return json.loads(r.read().decode()) + + +def _post_json(url: str, form: dict, headers: dict | None = None, timeout: int = 10) -> dict: + body = urllib.parse.urlencode(form).encode() + req = urllib.request.Request( + url, + data=body, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + **(headers or {}), + }, + ) + with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 + return json.loads(r.read().decode()) + + +# -------------------------------------------------------------------------- +# Authenticator +# -------------------------------------------------------------------------- + + +class Authenticator: + def __init__(self, cfg: Config) -> None: + self.cfg = cfg + self.providers: list[Provider] = [ + PROVIDERS[name](cfg) for name in cfg.active_providers() + ] + self.enabled = bool(self.providers) + + def provider(self, name: str) -> Provider | None: + return next((p for p in self.providers if p.name == name), None) + + # -- anonym ----------------------------------------------------------- + def anonymous(self) -> Principal: + """Uten konfigurerte leverandører får anonyme alt – dagens oppførsel. + + Med leverandører får de det [roles].anonymous sier, som er ingenting + som standard. Sett den til ["view"] om statusskjermen skal henge på + veggen uten innlogging.""" + if not self.enabled: + # Tomt navn: da står køen og loggen som før, uten en «bruker» som + # i praksis bare betyr «hvem som helst». + return Principal("anonymous", "", ("anonymous",), "none", frozenset(ACTIONS)) + roles = ("anonymous",) + return Principal("anonymous", "ikke innlogget", roles, "anonymous", self.cfg.permissions(roles)) + + # -- sesjon ----------------------------------------------------------- + def from_session(self) -> Principal | None: + raw = session.get(SESSION_KEY) + if not isinstance(raw, dict): + return None + if raw.get("exp") and float(raw["exp"]) < time.time(): + session.pop(SESSION_KEY, None) + return None + roles = tuple(str(r) for r in raw.get("roles", ())) + return Principal( + str(raw.get("subject", "?")), + str(raw.get("name", "?")), + roles, + str(raw.get("method", "jwt")), + # Rettigheter slås opp på nytt hver gang, så en endring i + # [roles] slår inn uten at folk må logge inn igjen. + self.cfg.permissions(roles), + ) + + def remember(self, p: Principal, seconds: int) -> None: + session.permanent = True + session[SESSION_KEY] = { + "subject": p.subject, + "name": p.name, + "roles": list(p.roles), + "method": p.method, + "exp": time.time() + seconds, + } + + # -- oppslag ---------------------------------------------------------- + def resolve(self) -> tuple[Principal, str]: + """Returnerer (principal, feilmelding). Kaster ikke – /api/config skal + kunne spørre «hvem er jeg?» uten å få 401 i fleisen.""" + p = self.from_session() + if p is not None: + return p, "" + for provider in self.providers: + try: + p = provider.authenticate(request) + except AuthError as e: + return self.anonymous(), str(e) + if p is not None: + return p, "" + return self.anonymous(), "" + + def describe(self) -> dict[str, Any]: + return { + "enabled": self.enabled, + "methods": [p.describe() for p in self.providers], + "logout_url": "/auth/logout" if self.enabled else None, + } + + +_auth: Authenticator | None = None + + +def current() -> Principal: + """Principalen for denne forespørselen, slått opp én gang.""" + if not hasattr(g, "principal"): + assert _auth is not None, "auth.install() er ikke kalt" + g.principal, g.auth_error = _auth.resolve() + return g.principal + + +def error() -> str: + current() + return getattr(g, "auth_error", "") + + +def require(action: str) -> Principal: + """Enten har denne brukeren lov, eller så stopper forespørselen her. + + 401 når vi ikke vet hvem det er (klienten skal logge inn), 403 når vi vet + hvem det er men rollen mangler handlingen. + """ + p = current() + if p.can(action): + return p + if p.anonymous: + abort(401, error() or _login_hint()) + abort(403, f"Rollen din ({', '.join(p.roles) or 'ingen'}) har ikke lov til dette.") + + +def _login_hint() -> str: + assert _auth is not None + names = {p.name for p in _auth.providers} + if names == {"pin"}: + return "Feil eller manglende PIN." + if "oidc" in names: + return "Du må logge inn." + return "Innlogging kreves." + + +# -------------------------------------------------------------------------- +# Oppsett og ruter +# -------------------------------------------------------------------------- + + +def secret_key(cfg: Config) -> bytes: + """Fast nøkkel, ellers logges alle ut ved hver restart av tjenesten.""" + configured = str(cfg.get("server", "secret_key", "")).strip() + if configured: + return configured.encode() + path = cfg.path("state") / "secret_key" + if path.is_file(): + return path.read_bytes() + path.parent.mkdir(parents=True, exist_ok=True) + key = secrets.token_bytes(32) + path.write_bytes(key) + path.chmod(0o600) + log.info("lagde ny sesjonsnøkkel i %s", path) + return key + + +bp = Blueprint("auth", __name__) + + +@bp.get("/auth/login") +def login(): + oidc = _auth.provider("oidc") if _auth else None + if not isinstance(oidc, OidcProvider): + abort(404, "OIDC er ikke satt opp") + try: + return redirect(oidc.authorize_url()) + except AuthError as e: + abort(502, str(e)) + + +@bp.get("/auth/callback") +def callback(): + oidc = _auth.provider("oidc") if _auth else None + if not isinstance(oidc, OidcProvider): + abort(404, "OIDC er ikke satt opp") + if err := request.args.get("error"): + abort(401, f"{err}: {request.args.get('error_description', '')}".strip(": ")) + state = session.pop("oidc_state", None) + verifier = session.pop("oidc_verifier", None) + if not state or not verifier or request.args.get("state") != state: + abort(400, "Innloggingen kom ut av takt. Prøv på nytt.") + code = request.args.get("code", "") + if not code: + abort(400, "Ingen kode fra identitetsleverandøren") + try: + tokens = oidc.exchange(code, verifier) + id_token = tokens.get("id_token") + if not id_token: + abort(502, "Fikk ikke id_token fra identitetsleverandøren") + claims = oidc.verifier().verify(id_token) + except AuthError as e: + abort(401, str(e)) + p = oidc.principal(claims) + ttl = int(claims.get("exp", 0) - time.time()) if claims.get("exp") else 0 + _auth.remember(p, max(ttl, 3600)) + log.info("innlogget %s (%s) via oidc", p.name, p.subject) + return redirect("/") + + +@bp.get("/auth/token") +def token_login(): + """Bro mens crew-panelet mangler OIDC: panelet lenker hit med et kortlevd + JWT, vi verifiserer det og setter sesjonscookien. Ett klikk, ingen PIN.""" + provider = _auth.provider("jwt") if _auth else None + if not isinstance(provider, JwtProvider): + abort(404, "JWT er ikke satt opp") + raw = request.args.get("t") or request.args.get("token") or "" + if not raw.strip(): + abort(400, "Mangler token") + try: + claims = provider.verifier.verify(raw.strip()) + except AuthError as e: + abort(401, str(e)) + p = provider.principal(claims) + ttl = int(claims.get("exp", 0) - time.time()) if claims.get("exp") else 0 + _auth.remember(p, max(ttl, provider.session_seconds)) + log.info("innlogget %s (%s) via token-lenke", p.name, p.subject) + return redirect("/") + + +@bp.get("/auth/logout") +def logout(): + session.clear() + oidc = _auth.provider("oidc") if _auth else None + if isinstance(oidc, OidcProvider) and oidc._meta: + if end := oidc._meta.get("end_session_endpoint"): + return redirect(end) + return redirect("/") + + +def install(app, cfg: Config) -> Authenticator: # noqa: ANN001 + """Kobler autentisering på Flask-appen. Returnerer Authenticatoren.""" + global _auth + _auth = Authenticator(cfg) + app.secret_key = secret_key(cfg) + app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + # Secure bare bak TLS: på rå HTTP over LAN/NetBird ville cookien + # aldri blitt sendt, og ingen ville klart å logge inn. + SESSION_COOKIE_SECURE=bool(cfg.get("server", "behind_tls", False)), + ) + app.register_blueprint(bp) + return _auth diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..34629b6 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,45 @@ +# Testoppsett, ikke produksjon: docker compose up --build +# +# http://localhost:8091 announcer (PIN + JWT + OIDC slått på) +# http://localhost:9000 dummy-IdP som later som den er crew-panelet +# +# Porten er 8091 med vilje, så den ikke kolliderer med tjenesten på 8090. +name: announcer-test + +services: + announcer: + build: . + ports: ["8091:8090"] + user: "1000:1000" + environment: + ANNOUNCER_CONFIG: /app/docker/config.test.toml + # Buffer, logg og sesjonsnøkkel holdes utenfor repoet, så testing ikke + # blander seg med det den kjørende tjenesten eier. + ANNOUNCER_CACHE: /data/cache + ANNOUNCER_LOG: /data/logs/announcements.jsonl + ANNOUNCER_STATE: /data/instance + ANNOUNCER_VOICES: /app/voices + ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte + ANNOUNCER_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker + # Må peke dit nettleseren ser appen, ellers blir OIDC-redirecten feil. + ANNOUNCER_PUBLIC_URL: http://localhost:8091 + XDG_RUNTIME_DIR: /run/user/1000 + volumes: + - .:/app + - ./.docker-data:/data + # Lyd: gir containeren vertens PipeWire. Kommenter ut hvis du bare + # skal teste innlogging og UI – da feiler avspilling, men ikke annet. + - /run/user/1000:/run/user/1000 + depends_on: [idp] + + idp: + build: . + ports: ["9000:9000"] + user: "1000:1000" + environment: + IDP_ISSUER: http://localhost:9000 + IDP_CLIENT_ID: announcer + IDP_CLIENT_SECRET: test-client-secret-kun-for-docker + volumes: + - .:/app + command: ["python", "docker/dummy_idp.py"] diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..2a0969d --- /dev/null +++ b/config.example.toml @@ -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 = ["*"] diff --git a/config.py b/config.py new file mode 100644 index 0000000..9a74a19 --- /dev/null +++ b/config.py @@ -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) diff --git a/docker/config.test.toml b/docker/config.test.toml new file mode 100644 index 0000000..cb911f1 --- /dev/null +++ b/docker/config.test.toml @@ -0,0 +1,39 @@ +# Konfigurasjon for docker compose-testingen. Alle tre innloggingsmetodene er +# på samtidig, noe man nok ikke vil i virkeligheten, men her er poenget å +# kunne prøve dem. +# +# Hemmelighetene settes som miljøvariabler i compose.yaml, ikke her. + +[app] +title = "Announcer Test" +subtitle = "Docker-oppsett – PIN, JWT og OIDC er alle slått på" +accent = "#f5a623" +quick_phrases = ["Dette er en test", "Pizzaen er kommet. Hent i kantina!"] + +[auth] +providers = ["oidc", "jwt", "pin"] + +[auth.pin] +pin = "1234" +role = "crew" +hint = "1234 i dette testoppsettet" + +[auth.jwt] +issuer = "http://localhost:9000" +audience = "announcer" +role_claim = "roles" + +[auth.oidc] +issuer = "http://localhost:9000" +client_id = "announcer" +label = "Logg inn med dummy-IdP" +# Delt horisont: nettleseren ser IdP-en som localhost:9000, mens announcer +# selv snakker med den som idp:9000 inne i compose-nettet. Derfor settes +# endepunktene manuelt istedenfor å bruke discovery. +authorization_endpoint = "http://localhost:9000/oauth/authorize" +token_endpoint = "http://idp:9000/oauth/token" + +[roles] +anonymous = ["view"] +crew = ["view", "preview", "announce"] +admin = ["*"] diff --git a/docker/dummy_idp.py b/docker/dummy_idp.py new file mode 100644 index 0000000..b1dfdfa --- /dev/null +++ b/docker/dummy_idp.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Dummy identitetsleverandør for testing. Later som den er crew-panelet. + +Crew-panelet har ikke OIDC ennå, så uten noe slikt kan ikke OIDC-veien prøves +i det hele tatt. Denne gjør det mulig: full autorisasjonskode-runde med PKCE, +og du velger selv hvilken rolle du vil logge inn med. + +Den er *ikke* en IdP. Den signerer med klienthemmeligheten (HS256), lagrer +koder i minnet og spør ikke om passord. Bruk den bare bak compose. +""" + +from __future__ import annotations + +import base64 +import hashlib +import os +import secrets +import time +import urllib.parse + +import jwt +from flask import Flask, abort, jsonify, redirect, request + +ISSUER = os.environ.get("IDP_ISSUER", "http://localhost:9000") +CLIENT_ID = os.environ.get("IDP_CLIENT_ID", "announcer") +CLIENT_SECRET = os.environ.get("IDP_CLIENT_SECRET", "test-client-secret-kun-for-docker") + +# Folk man kan logge inn som. Rollenavnene må finnes i announcers [roles]. +USERS = { + "kari": {"sub": "101", "name": "Kari Nordmann", "email": "kari@crew.test", "roles": ["crew"]}, + "kent": {"sub": "1", "name": "Kent", "email": "kent@crew.test", "roles": ["admin"]}, + "ola": {"sub": "102", "name": "Ola Utenfor", "email": "ola@crew.test", "roles": []}, +} + +app = Flask(__name__) +codes: dict[str, dict] = {} + + +@app.get("/.well-known/openid-configuration") +def discovery(): + return jsonify({ + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/oauth/authorize", + "token_endpoint": f"{ISSUER}/oauth/token", + "response_types_supported": ["code"], + "id_token_signing_alg_values_supported": ["HS256"], + "code_challenge_methods_supported": ["S256"], + }) + + +@app.get("/oauth/authorize") +def authorize(): + if request.args.get("client_id") != CLIENT_ID: + abort(400, "ukjent client_id") + for required in ("redirect_uri", "state", "code_challenge"): + if not request.args.get(required): + abort(400, f"mangler {required}") + if request.args.get("code_challenge_method") != "S256": + abort(400, "krever PKCE med S256") + + who = request.args.get("who") + if who not in USERS: + # Ingen innloggingsside, bare en liste å trykke på. + rows = "".join( + f'
  • ' + f'{v["name"]} – roller: {", ".join(v["roles"]) or "ingen"}
  • ' + for k, v in USERS.items() + ) + return f"""Dummy-IdP + +

    Dummy-IdP

    +

    Står for crew-panelet. Velg hvem du vil logge inn som:

    +
      {rows}
    +

    Ola har ingen roller – han skal få 403, ikke 401.

    """ + + code = secrets.token_urlsafe(24) + codes[code] = { + "user": who, + "challenge": request.args["code_challenge"], + "redirect_uri": request.args["redirect_uri"], + "exp": time.time() + 300, + } + sep = "&" if "?" in request.args["redirect_uri"] else "?" + params = urllib.parse.urlencode({"code": code, "state": request.args["state"]}) + return redirect(f"{request.args['redirect_uri']}{sep}{params}") + + +@app.post("/oauth/token") +def token(): + code = request.form.get("code", "") + entry = codes.pop(code, None) # engangsbruk + if not entry or entry["exp"] < time.time(): + return jsonify({"error": "invalid_grant"}), 400 + if request.form.get("redirect_uri") != entry["redirect_uri"]: + return jsonify({"error": "invalid_grant", "error_description": "redirect_uri stemmer ikke"}), 400 + + # PKCE: verifier skal hashe til challenge vi fikk i authorize. + verifier = request.form.get("code_verifier", "") + expected = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode()).digest() + ).rstrip(b"=").decode() + if not verifier or expected != entry["challenge"]: + return jsonify({"error": "invalid_grant", "error_description": "PKCE stemmer ikke"}), 400 + + user = USERS[entry["user"]] + claims = { + "iss": ISSUER, + "aud": CLIENT_ID, + "iat": int(time.time()), + "exp": int(time.time()) + 3600, + **user, + } + return jsonify({ + "access_token": secrets.token_urlsafe(16), + "token_type": "Bearer", + "expires_in": 3600, + "id_token": jwt.encode(claims, CLIENT_SECRET, algorithm="HS256"), + }) + + +@app.get("/token") +def mint(): + """Lager et JWT til token-lenka og til curl, slik panelet skal gjøre. + + http://localhost:9000/token?who=kari -> rått token + http://localhost:9000/token?who=kari&go=1 -> logger deg rett inn + """ + user = USERS.get(request.args.get("who", "kari")) + if not user: + abort(404, "ukjent bruker") + raw = jwt.encode( + {"iss": ISSUER, "aud": CLIENT_ID, "exp": int(time.time()) + 300, **user}, + os.environ.get("ANNOUNCER_JWT_SECRET", "test-hemmelighet-kun-for-docker-ikke-bruk-ekte"), + algorithm="HS256", + ) + if request.args.get("go"): + base = os.environ.get("ANNOUNCER_PUBLIC_URL", "http://localhost:8091") + return redirect(f"{base}/auth/token?t={raw}") + return raw, 200, {"Content-Type": "text/plain"} + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=9000, threaded=True) diff --git a/docs/crew-integration.md b/docs/crew-integration.md new file mode 100644 index 0000000..752a3b0 --- /dev/null +++ b/docs/crew-integration.md @@ -0,0 +1,204 @@ +# Koble announcer til crew-panelet + +Announcer har ingen brukerdatabase og skal ikke ha det. Den stoler på +crew-panelet og leser identitet og roller ut av det panelet sier. Dette +dokumentet er kontrakten: hva panelet må levere, og i hvilken rekkefølge det er +verdt å bygge det. + +Panelet har i dag verken JWT eller OIDC. **Bygg JWT først** – det er noen titalls +linjer i panelet og dekker behovet. OIDC kan komme etterpå uten at noe i +announcer må skrives om; begge veier ender i samme kode. + +--- + +## Trinn 1: JWT (anbefalt start) + +Panelet signerer et kortlevd token som sier hvem brukeren er og hvilke roller +han har. Announcer verifiserer signaturen og stoler på innholdet. + +### Hva tokenet skal inneholde + +```json +{ + "iss": "https://crew.example.no", + "aud": "announcer", + "sub": "142", + "name": "Kari Nordmann", + "roles": ["crew"], + "exp": 1760000000 +} +``` + +| Claim | Krav | Merknad | +|---|---|---| +| `iss` | må stemme med `auth.jwt.issuer` | sjekkes bare hvis den er satt i config | +| `aud` | må stemme med `auth.jwt.audience` | bruk `"announcer"` | +| `sub` | påkrevd i praksis | stabil bruker-id; havner i loggen som `jwt:` | +| `exp` | **påkrevd** | announcer avviser tokens uten utløpstid | +| `name` | valgfri | vises i køen. Faller tilbake på `preferred_username`, `email`, så `sub` | +| `roles` | valgfri | se under | + +`roles` kan være en liste (`["crew"]`), en streng (`"crew"`), mellomroms- eller +kommadelt (`"crew admin"`), og den kan ligge nestet – da peker du på den med +`role_claim = "realm_access.roles"`. Mangler den helt, får brukeren +`auth.jwt.default_role`, eller ingen rettigheter hvis den ikke er satt. + +Rollenavnene betyr ingenting for announcer i seg selv; `[roles]` i `config.toml` +bestemmer hva de får lov til. + +### Signering + +To varianter. Begge virker, den andre er bedre: + +**HS256 med delt hemmelighet** – enklest. Samme streng i panelet og i +announcer. Ulempen er at announcer dermed har en nøkkel som *kan lage* gyldige +tokens. + +```toml +[auth.jwt] +issuer = "https://crew.example.no" +audience = "announcer" +# settes som ANNOUNCER_JWT_SECRET i systemd-enheten, ikke her +``` + +**RS256 med JWKS** – panelet signerer med sin private nøkkel og publiserer den +offentlige på `/.well-known/jwks.json`. Announcer henter den og cacher den en +time. Da kan announcer bare verifisere, aldri signere, og nøkkelrullering +skjer av seg selv. + +```toml +[auth.jwt] +issuer = "https://crew.example.no" +audience = "announcer" +jwks_url = "https://crew.example.no/.well-known/jwks.json" +``` + +### Hvordan brukeren kommer inn + +To måter, og panelet kan bruke begge: + +**Lenke (det du vil ha for et crew-medlem).** Panelet lager en knapp «Åpne +announcer» som peker på + +``` +http://:8090/auth/token?t= +``` + +Announcer verifiserer tokenet, setter en sesjonscookie og sender brukeren til +forsida. Ett klikk, ingen PIN å huske. Lag tokenet kortlevd (`exp` et par +minutter fram) – det er bare en billett inn; sesjonen varer så lenge tokenets +`exp` sier, eller `session_seconds` hvis det er lengre. + +**Header (for skript og integrasjoner).** Send tokenet på hver forespørsel: + +```bash +curl -X POST http://announcer:8090/api/announce \ + -H "Authorization: Bearer $JWT" \ + -H 'Content-Type: application/json' \ + -d '{"text":"Testmelding","voice":"finn"}' +``` + +### Minimum i panelet (Python-eksempel) + +```python +import time, jwt # pyjwt + +def announcer_url(user) -> str: + token = jwt.encode( + { + "iss": "https://crew.example.no", + "aud": "announcer", + "sub": str(user.id), + "name": user.full_name, + "roles": ["admin" if user.is_crew_lead else "crew"], + "exp": time.time() + 120, + }, + ANNOUNCER_JWT_SECRET, + algorithm="HS256", + ) + return f"http://announcer.lan:8090/auth/token?t={token}" +``` + +--- + +## Trinn 2: OIDC + +Når panelet har fått `authorize`-, `token`- og gjerne `userinfo`-endepunkter, +kan announcer bli en vanlig OIDC-klient. Da logger brukeren inn i panelet i +nettleseren og trenger ingen lenke med token i. + +Announcer bruker autorisasjonskode med PKCE (S256). Dette må registreres i +panelet: + +| | | +|---|---| +| `client_id` | f.eks. `announcer` | +| `redirect_uri` | `http://:8090/auth/callback` – **eksakt**, inkludert port | +| `grant_type` | `authorization_code` | +| `scope` | `openid profile email` (eller bare `openid`) | +| PKCE | `S256` | + +Announcer leser `{issuer}/.well-known/openid-configuration` ved første +innlogging. Har ikke panelet discovery, settes endepunktene direkte: + +```toml +[auth.oidc] +issuer = "https://crew.example.no" +client_id = "announcer" +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" +``` + +`client_secret` settes som `ANNOUNCER_OIDC_CLIENT_SECRET`. Rollene leses ut av +`id_token` på samme måte som over. + +Står announcer bak en omvendt proxy, sett `server.public_url` så +`redirect_uri` blir riktig, og `server.behind_tls = true`. + +--- + +## Roller og rettigheter + +Fem handlinger. `config.toml` kobler rollenavn til dem: + +| Handling | Hva den gir | +|---|---| +| `view` | se køen og hva som spilles nå | +| `preview` | forhåndslytte (koster en syntese) | +| `announce` | sende ut på anlegget | +| `stop` | stoppe det som spilles | +| `clear` | tømme køen (og se IP-adresser i køen) | + +```toml +[roles] +anonymous = [] # ["view"] for en statusskjerm +crew = ["view", "preview", "announce"] +admin = ["*"] +``` + +Endrer du `[roles]` slår det inn ved neste forespørsel – ingen må logge inn på +nytt. (Nye `[auth.*]`-verdier krever omstart.) + +## Hva announcer svarer + +| Kode | Betyr | Hva klienten skal gjøre | +|---|---|---| +| 401 | vi vet ikke hvem du er | logg inn | +| 403 | vi vet hvem du er, rollen din får ikke dette | ikke prøv igjen | + +`GET /api/config` svarer alltid 200 og forteller i `auth`-blokka hvilke metoder +som er aktive, hvem du er og hva du får gjøre. Det er den sida bruker for å +vise eller skjule knapper. + +## Loggen + +Hvert utrop havner i `logs/announcements.jsonl`, én JSON-linje per hendelse: + +```json +{"ts":1760000000.12,"action":"announce","subject":"jwt:142","name":"Kari Nordmann", + "method":"jwt","ip":"10.20.70.55","id":7,"text":"Pizzaen er kommet","voice":"finn"} +``` + +`action` er `announce`, `played`, `stop` eller `clear`. Fila rullerer til +`.jsonl.1` ved 5 MB. diff --git a/index.html b/index.html index af2cb03..b2dcbb8 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ -SG Announcer +Announcer
    -

    SG Announcer

    -

    Skriv meldingen, forhåndslytt, og send den ut på anlegget.

    +
    +
    +

    +

    +
    +
    +
    + +
    +
    +
    - - + + +
    - +
    0 / 600
    @@ -122,68 +138,165 @@ const $ = s => document.querySelector(s); const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime'); const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce'); -let cfg = {}, previewedKey = null; - -const QUICK = [ - 'Hei alle sammen! Pizzaen er kommet, hent i kantina.', - 'Turneringen starter om ti minutter. Møt opp ved scenen.', - 'Husk å rydde rundt plassen din. Takk!', - 'Natta! Vi senker lyset, hold volumet nede.', -]; +let cfg = {}, can = [], previewedKey = null, pollTimer = null; function key() { return JSON.stringify(payload()); } function payload() { return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.value }; } -function headers() { - const h = { 'Content-Type': 'application/json' }; +function headers(json) { + const h = json ? { 'Content-Type': 'application/json' } : {}; + // PIN-en er den eneste legitimasjonen som ligger i nettleseren; OIDC og + // token-lenka bruker sesjonscookien, som følger med av seg selv. const pin = localStorage.getItem('pin'); if (pin) h['X-Pin'] = pin; return h; } function say(t, cls) { msg.textContent = t; msg.className = 'msg ' + (cls || ''); } +function may(action) { return can.includes(action); } function updateAnnounce() { - btnAnnounce.disabled = !(previewedKey && previewedKey === key()); - btnAnnounce.title = btnAnnounce.disabled ? 'Forhåndslytt først' : ''; + const ready = previewedKey && previewedKey === key(); + btnAnnounce.disabled = !ready || !may('announce'); + btnAnnounce.title = !may('announce') ? 'Du har ikke lov til å sende ut' : + (btnAnnounce.disabled ? 'Forhåndslytt først' : ''); + btnPreview.disabled = !may('preview'); + btnPreview.title = may('preview') ? '' : 'Du har ikke lov til å forhåndslytte'; } -async function api(path, body) { - const r = await fetch(path, { method: 'POST', headers: headers(), body: JSON.stringify(body || {}) }); + +async function req(path, opts) { + const r = await fetch(path, opts); if (!r.ok) { - let e = r.statusText; try { e = (await r.json()).error || e; } catch {} - throw new Error(e); + let e = r.statusText, login = false; + try { const j = await r.json(); e = j.error || e; login = !!j.login; } catch {} + const err = new Error(e); + err.status = r.status; err.login = login; + throw err; } return r; } +const api = (path, body) => + req(path, { method: 'POST', headers: headers(true), body: JSON.stringify(body || {}) }); +function onError(e) { + say(e.message, 'err'); + // Rettighetene kan ha endret seg (riktig PIN skrevet, sesjon utløpt). + if (e.status === 401 || e.status === 403) refreshAuth(); +} + +// ---------------------------------------------------------------- branding +function applyConfig(c) { + cfg = c; + const a = c.app || {}; + document.title = a.title || 'Announcer'; + // Siste ord i tittelen får aksentfargen; ett ord alene står som det er. + const parts = (a.title || 'Announcer').split(' '); + $('#title').textContent = ''; + if (parts.length > 1) { + $('#title').append(document.createTextNode(parts.slice(0, -1).join(' ') + ' ')); + const sp = document.createElement('span'); sp.textContent = parts.at(-1); $('#title').append(sp); + } else { + $('#title').textContent = parts[0]; + } + $('#subtitle').textContent = a.subtitle || ''; + if (a.accent) document.documentElement.style.setProperty('--accent', a.accent); + text.placeholder = a.message_placeholder || ''; + $('#max').textContent = c.max_chars; text.maxLength = c.max_chars; + + $('#chips').replaceChildren(...(a.quick_phrases || []).map(q => { + const b = document.createElement('button'); + b.className = 'chip'; b.textContent = q.slice(0, 32) + (q.length > 32 ? '…' : ''); b.title = q; + b.onclick = () => { text.value = q; text.dispatchEvent(new Event('input')); text.focus(); }; + return b; + })); +} + +// -------------------------------------------------------------------- auth +function applyAuth(auth) { + can = auth.can || []; + const methods = auth.methods || []; + const who = $('#who'); + + if (auth.user) { + who.textContent = ''; + who.append(document.createTextNode('Innlogget som ')); + const b = document.createElement('b'); b.textContent = auth.user.name; who.append(b); + if (auth.user.roles.length) who.append(document.createTextNode(' (' + auth.user.roles.join(', ') + ')')); + if (auth.logout_url) { + who.append(document.createTextNode(' · ')); + const a = document.createElement('a'); a.href = auth.logout_url; a.textContent = 'Logg ut'; who.append(a); + } + } else if (auth.enabled) { + who.textContent = 'Ikke innlogget'; + } else { + who.textContent = ''; + } + + // OIDC-knapper + const oidc = methods.filter(m => m.login_url); + $('#loginbox').style.display = (oidc.length && !auth.user) ? 'block' : 'none'; + $('#loginbtns').replaceChildren(...oidc.map(m => { + const a = document.createElement('a'); + a.className = 'btn primary'; a.href = m.login_url; a.textContent = m.label || 'Logg inn'; + return a; + })); + + // PIN-feltet, bare når PIN er en av metodene og vi ikke alt er innlogget + const pin = methods.find(m => m.method === 'pin'); + $('#pinbox').style.display = (pin && !auth.user) ? 'block' : 'none'; + if (pin) { + $('#pinlabel').textContent = pin.label || 'PIN'; + $('#pinhint').textContent = auth.error || pin.hint || ''; + $('#pinhint').style.color = auth.error ? 'var(--danger)' : 'var(--muted)'; + if (!$('#pin').value) $('#pin').value = localStorage.getItem('pin') || ''; + } + + $('#btnStop').classList.toggle('hidden', !may('stop')); + $('#btnClear').classList.toggle('hidden', !may('clear')); + updateAnnounce(); +} + +async function refreshAuth() { + try { + const c = await (await fetch('/api/config', { headers: headers(false) })).json(); + applyAuth(c.auth || {}); + } catch {} +} + +// -------------------------------------------------------------------- init async function init() { - cfg = await (await fetch('/api/config')).json(); + const c = await (await fetch('/api/config', { headers: headers(false) })).json(); + applyConfig(c); + const groups = {}; - for (const v of cfg.voices) { + for (const v of c.voices) { if (!groups[v.group]) { groups[v.group] = document.createElement('optgroup'); groups[v.group].label = v.group; voice.append(groups[v.group]); } const o = document.createElement('option'); o.value = v.id; o.textContent = v.name; groups[v.group].append(o); } - voice.value = localStorage.getItem('voice') || cfg.default_voice; - if (!voice.value) voice.value = cfg.default_voice; - for (const c of cfg.chimes) { - const o = document.createElement('option'); o.value = c.id; o.textContent = c.name; chime.append(o); + voice.value = localStorage.getItem('voice') || c.default_voice; + if (!voice.value) voice.value = c.default_voice; + for (const ch of c.chimes) { + const o = document.createElement('option'); o.value = ch.id; o.textContent = ch.name; chime.append(o); } - chime.value = localStorage.getItem('chime') || cfg.default_chime; - if (!chime.value) chime.value = cfg.default_chime; - $('#max').textContent = cfg.max_chars; text.maxLength = cfg.max_chars; - if (cfg.pin_required) { $('#pinbox').style.display = 'block'; $('#pin').value = localStorage.getItem('pin') || ''; } - for (const q of QUICK) { - const b = document.createElement('button'); b.className = 'chip'; b.textContent = q.slice(0, 32) + (q.length > 32 ? '…' : ''); - b.title = q; b.onclick = () => { text.value = q; text.dispatchEvent(new Event('input')); text.focus(); }; - $('#chips').append(b); - } - poll(); setInterval(poll, 2000); + chime.value = localStorage.getItem('chime') || c.default_chime; + if (!chime.value) chime.value = c.default_chime; + + applyAuth(c.auth || {}); + poll(); + pollTimer = setInterval(poll, 2000); } +let pinDebounce = null; +$('#pin').addEventListener('input', e => { + localStorage.setItem('pin', e.target.value); + clearTimeout(pinDebounce); + // Vent til han er ferdig å skrive, så slipper vi en runde per tastetrykk. + pinDebounce = setTimeout(() => { refreshAuth(); poll(); }, 600); +}); + text.addEventListener('input', () => { $('#count').textContent = text.value.length; updateAnnounce(); }); voice.addEventListener('change', () => { localStorage.setItem('voice', voice.value); updateAnnounce(); }); speed.addEventListener('input', () => { $('#speedval').textContent = parseFloat(speed.value).toFixed(2).replace(/0$/, '') + '×'; updateAnnounce(); }); chime.addEventListener('change', () => { localStorage.setItem('chime', chime.value); updateAnnounce(); }); -$('#pin').addEventListener('input', e => localStorage.setItem('pin', e.target.value)); btnPreview.addEventListener('click', async () => { const p = payload(); @@ -195,10 +308,10 @@ btnPreview.addEventListener('click', async () => { if (audio.src) URL.revokeObjectURL(audio.src); audio.src = URL.createObjectURL(blob); audio.classList.add('show'); await audio.play().catch(() => {}); - previewedKey = JSON.stringify(p); updateAnnounce(); + previewedKey = JSON.stringify(p); say('Hør gjennom. Fornøyd? Send den ut.', 'ok'); - } catch (e) { say(e.message, 'err'); } - finally { btnPreview.disabled = false; } + } catch (e) { onError(e); } + finally { btnPreview.disabled = false; updateAnnounce(); } }); btnAnnounce.addEventListener('click', async () => { @@ -209,29 +322,40 @@ btnAnnounce.addEventListener('click', async () => { text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null; audio.pause(); audio.classList.remove('show'); poll(); - } catch (e) { say(e.message, 'err'); updateAnnounce(); } + } catch (e) { onError(e); } + finally { updateAnnounce(); } }); -$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(e => say(e.message, 'err'))); -$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(e => say(e.message, 'err'))); +$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(onError)); +$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(onError)); const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø' }; function hhmm(ts) { return new Date(ts * 1000).toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' }); } function li(it) { const l = document.createElement('li'); l.className = it.status; - l.innerHTML = `${hhmm(it.created)}${STATUS[it.status] || it.status}${it.error ? ' · ' + it.error : ''}`; + l.innerHTML = ``; + l.querySelector('.t').textContent = hhmm(it.created); l.querySelector('.x').textContent = it.text; + // Navnet står her, ikke IP-en. IP-en kommer bare med for en admin. + l.querySelector('.by').textContent = [it.user, it.sender].filter(Boolean).join(' · '); + l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : ''); return l; } async function poll() { + const now = $('#now'); try { - const s = await (await fetch('/api/status')).json(); - const now = $('#now'); + const s = await (await req('/api/status', { headers: headers(false) })).json(); if (s.current) { now.className = 'now'; now.innerHTML = ''; now.append(document.createTextNode(s.current.text)); } - else { now.className = 'now idle'; now.textContent = ''; } + else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; } $('#queue').replaceChildren(...s.queued.map(li)); $('#history').replaceChildren(...s.history.map(li)); - } catch {} + } catch (e) { + if (e.status === 401 || e.status === 403) { + now.className = 'now idle'; + now.textContent = 'Logg inn for å se køen.'; + $('#queue').replaceChildren(); $('#history').replaceChildren(); + } + } } init(); diff --git a/install.sh b/install.sh index 7c0e8b9..fc16cc3 100755 --- a/install.sh +++ b/install.sh @@ -1,12 +1,14 @@ #!/usr/bin/env bash -# Installerer SG Announcer på en Debian/Ubuntu-maskin (testet mot Linux Mint). +# Installerer Announcer på en Debian/Ubuntu-maskin (testet mot Linux Mint). # -# git clone https://git.1x1.no/kent/sg-announcer.git -# cd sg-announcer && ./install.sh +# git clone https://git.1x1.no/kent/announcer.git +# cd announcer && ./install.sh # # Kjør som vanlig bruker (ikke sudo); skriptet spør om passord der det trengs. # Valgfritt: ANNOUNCER_PIN=1234 ./install.sh -> sida krever PIN # ANNOUNCER_PORT=8090 ./install.sh -> annen port +# ANNOUNCER_TITLE="Utrop" ./install.sh +# Resten av konfigurasjonen hører hjemme i config.toml, se config.example.toml. # Kan kjøres på nytt; da oppdateres avhengigheter og tjenesten startes om. set -euo pipefail @@ -15,11 +17,14 @@ if [[ $EUID -eq 0 ]]; then exit 1 fi +APP="announcer" +OLD_APP="sg-announcer" # het dette før; enheten ryddes bort nedenfor REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PORT="${ANNOUNCER_PORT:-8090}" PIN="${ANNOUNCER_PIN:-}" +TITLE="${ANNOUNCER_TITLE:-}" UNIT_DIR="$HOME/.config/systemd/user" -UNIT="$UNIT_DIR/sg-announcer.service" +UNIT="$UNIT_DIR/$APP.service" step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } @@ -44,17 +49,27 @@ echo "uv: $UV ($("$UV" --version))" step "Python 3.13 og avhengigheter" cd "$REPO_DIR" "$UV" sync -mkdir -p cache chimes voices +mkdir -p cache chimes voices logs instance step "Laster ned Piper-stemmene (lokal reserve, ca. 600 MB, kun første gang)" "$UV" run python -c "import logging, announcer; logging.basicConfig(level=logging.INFO, format='%(message)s'); announcer.ensure_voices()" +step "Rydder bort gammel tjeneste" +# Tjenesten het sg-announcer før. Uten dette slåss to enheter om porten. +if [[ -f "$UNIT_DIR/$OLD_APP.service" ]]; then + systemctl --user disable --now "$OLD_APP" >/dev/null 2>&1 || true + rm -f "$UNIT_DIR/$OLD_APP.service" + echo "fjernet $OLD_APP.service" +else + echo "ingenting å rydde." +fi + step "systemd-brukertjeneste" mkdir -p "$UNIT_DIR" { cat < "$UNIT" +chmod 600 "$UNIT" # PIN og nøkler står i klartekst her systemctl --user daemon-reload -systemctl --user enable --now sg-announcer -systemctl --user restart sg-announcer +systemctl --user enable --now "$APP" +systemctl --user restart "$APP" step "Brannmur" if ip link show wt0 >/dev/null 2>&1; then @@ -87,14 +111,14 @@ if ip link show wt0 >/dev/null 2>&1; then sudo ufw default allow outgoing sudo ufw allow in on wt0 to any port 22 proto tcp comment "ssh via netbird" sudo ufw allow in on wt0 to any port 22022 proto tcp comment "netbird ssh-server" - sudo ufw allow in on wt0 to any port "$PORT" proto tcp comment "sg-announcer via netbird" + sudo ufw allow in on wt0 to any port "$PORT" proto tcp comment "$APP via netbird" sudo ufw delete allow "$PORT/tcp" >/dev/null 2>&1 || true sudo ufw --force enable echo "ufw aktiv: port $PORT, 22 og 22022 er åpne kun på wt0 (NetBird)." echo "Vil du også nå sida fra lokalt LAN uten VPN:" echo " sudo ufw allow from to any port $PORT proto tcp" elif sudo ufw status 2>/dev/null | grep -q "Status: active"; then - sudo ufw allow "$PORT/tcp" comment "sg-announcer" + sudo ufw allow "$PORT/tcp" comment "$APP" else echo "ufw er ikke aktiv, ingen regel nødvendig." fi @@ -102,16 +126,20 @@ fi sleep 2 IP="$(hostname -I 2>/dev/null | awk '{print $1}')" step "Ferdig" -systemctl --user --no-pager status sg-announcer | head -5 || true +systemctl --user --no-pager status "$APP" | head -5 || true cat <}:$PORT fra hva som helst på nettet. +Uten config.toml er sida åpen for alle som når porten. Vil du ha innlogging: + cp config.example.toml config.toml og les kommentarene der. + Mot crew-panelet: docs/crew-integration.md + Tips for en laptop på arrangementet: - Lyden spilles i din innloggede økt, så maskinen må være logget inn. Slå på automatisk innlogging (Innstillinger -> Påloggingsvindu). - Hindre at den sover med lokket igjen: sudo sed -i 's/^#\?HandleLidSwitch=.*/HandleLidSwitch=ignore/' /etc/systemd/logind.conf sudo systemctl restart systemd-logind - - Logg: journalctl --user -u sg-announcer -f + - Logg: journalctl --user -u $APP -f MSG diff --git a/pyproject.toml b/pyproject.toml index ae02e26..9f65df8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,21 @@ [project] -name = "sg-announcer" -version = "0.1.0" -description = "Add your description here" +name = "announcer" +version = "0.2.0" +description = "Tekst inn i nettleseren, forhåndslytt, og les meldingen ut på høyttaleranlegget" requires-python = ">=3.13" dependencies = [ "edge-tts>=7.2.8", "flask>=3.1.3", "piper-tts>=1.8.0", + "pyjwt[crypto]>=2.10", ] -[project.scripts] -sg-announcer = "sg_announcer:main" +[dependency-groups] +dev = [ + "pytest>=8.3", +] -[build-system] -requires = ["uv_build>=0.12.17,<0.13.0"] -build-backend = "uv_build" +[tool.uv] +# Appen er noen løse .py-filer som kjøres på plass, ikke en pakke som skal +# bygges og installeres. Da slipper vi også et build-backend. +package = false diff --git a/src/sg_announcer/__init__.py b/src/sg_announcer/__init__.py deleted file mode 100644 index be35944..0000000 --- a/src/sg_announcer/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -def main() -> None: - print("Hello from sg-announcer!") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f015824 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +"""Felles oppsett for testene. + +Hele poenget her er å nulle ut miljøet. `config.load()` lar +ANNOUNCER_*-variabler overstyre konfigurasjonen, og det er riktig oppførsel – +men det betyr at testene ellers arver det maskina tilfeldigvis har satt. +På announcer-maskina setter systemd-enheten `ANNOUNCER_PORT`, og i +docker compose settes både JWT- og OIDC-hemmeligheter. Uten denne fila feiler +28 av testene der, med feilmeldinger som ikke peker på noe som er galt. +""" + +from __future__ import annotations + +import os + +import pytest + + +@pytest.fixture(autouse=True) +def rent_miljø(monkeypatch): + """Fjerner alle ANNOUNCER_*-variabler, så hver test bare ser sin egen config.""" + for key in [k for k in os.environ if k.startswith("ANNOUNCER_")]: + monkeypatch.delenv(key, raising=False) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..199974a --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,535 @@ +"""Tester for autentiseringen. + +Auth er den ene delen av denne appen man ikke kan se etter med øyet: at en PIN +virker ser du med en gang, at et utløpt token blir avvist ser du aldri før det +smeller. Derfor står prøvene her og ikke rundt resten. + +Testene kjører mot en bar Flask-app, ikke mot announcer.py – det ville dratt inn +piper, lastet stemmer og startet avspillingstråden. +""" + +from __future__ import annotations + +import sys +import time +import urllib.parse +from pathlib import Path + +import jwt as pyjwt +import pytest +from flask import Flask, jsonify + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import auth # noqa: E402 +import config # noqa: E402 + +SECRET = "en-delt-hemmelighet-mellom-panelet-og-announcer" + + +def build(tmp_path: Path, toml: str = "", **env: str): + """Lager en liten app med gitt konfigurasjon. Returnerer testklienten.""" + cfg_path = tmp_path / "config.toml" + cfg_path.write_text(f'[paths]\nstate = "{tmp_path}"\nlog = "{tmp_path}/a.jsonl"\n\n{toml}') + for key, value in env.items(): + __import__("os").environ[key] = value + try: + cfg = config.load(cfg_path) + finally: + for key in env: + __import__("os").environ.pop(key, None) + + app = Flask(__name__) + app.config["TESTING"] = True + authenticator = auth.install(app, cfg) + + @app.get("/do/") + def do(action: str): # noqa: ANN202 + return jsonify(auth.require(action).public()) + + @app.errorhandler(401) + def _401(e): # noqa: ANN001, ANN202 + return jsonify({"error": e.description}), 401 + + @app.errorhandler(403) + def _403(e): # noqa: ANN001, ANN202 + return jsonify({"error": e.description}), 403 + + return app.test_client(), authenticator + + +def token(claims: dict, *, secret: str = SECRET, alg: str = "HS256", **over) -> str: + body = {"sub": "u1", "exp": time.time() + 600, **claims, **over} + return pyjwt.encode(body, secret, algorithm=alg) + + +# -------------------------------------------------------------------------- +# Uten konfigurasjon skal ingenting være stengt +# -------------------------------------------------------------------------- + + +def test_uten_konfig_er_alt_apent(tmp_path): + c, a = build(tmp_path) + assert a.enabled is False + for action in config.ACTIONS: + assert c.get(f"/do/{action}").status_code == 200, action + + +def test_tom_providers_liste_er_fortsatt_apen(tmp_path): + c, a = build(tmp_path, "[auth]\nproviders = []\n") + assert a.enabled is False + assert c.get("/do/clear").status_code == 200 + + +# -------------------------------------------------------------------------- +# PIN +# -------------------------------------------------------------------------- + + +def test_pin_kreves_nar_den_er_satt(tmp_path): + c, a = build(tmp_path, '[auth.pin]\npin = "1234"\n') + assert a.enabled is True + assert [p.name for p in a.providers] == ["pin"] + assert c.get("/do/announce").status_code == 401 + assert c.get("/do/announce", headers={"X-Pin": "feil"}).status_code == 401 + assert c.get("/do/announce", headers={"X-Pin": "1234"}).status_code == 200 + + +def test_pin_fra_miljovariabel_slar_pa_leverandoren(tmp_path): + """Bakoverkompatibilitet: dette er alt systemd-enheten har satt fram til nå.""" + c, a = build(tmp_path, ANNOUNCER_PIN="9999") + assert [p.name for p in a.providers] == ["pin"] + assert c.get("/do/clear", headers={"X-Pin": "9999"}).status_code == 200 + assert c.get("/do/clear").status_code == 401 + + +def test_feil_pin_gir_forstaelig_melding(tmp_path): + c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n') + assert c.get("/do/announce", headers={"X-Pin": "nope"}).get_json()["error"] == "Feil PIN" + + +def test_pin_gir_admin_som_standard(tmp_path): + c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n') + body = c.get("/do/clear", headers={"X-Pin": "1234"}).get_json() + assert body["roles"] == ["admin"] + assert sorted(body["can"]) == sorted(config.ACTIONS) + + +def test_pin_med_crew_rolle_far_ikke_tomme_koen(tmp_path): + c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\nrole = "crew"\n') + assert c.get("/do/announce", headers={"X-Pin": "1234"}).status_code == 200 + r = c.get("/do/clear", headers={"X-Pin": "1234"}) + assert r.status_code == 403 + assert "crew" in r.get_json()["error"] + + +def test_pin_godtas_ogsa_i_authorization_header(tmp_path): + c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n') + assert c.get("/do/announce", headers={"Authorization": "Pin 1234"}).status_code == 200 + + +# -------------------------------------------------------------------------- +# Anonym rolle +# -------------------------------------------------------------------------- + + +def test_anonym_kan_gis_leserett(tmp_path): + """For statusskjermen som henger på veggen uten noen til å logge inn.""" + c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n\n[roles]\nanonymous = ["view"]\n') + assert c.get("/do/view").status_code == 200 + assert c.get("/do/announce").status_code == 401 + + +# -------------------------------------------------------------------------- +# JWT +# -------------------------------------------------------------------------- + + +JWT_CFG = f''' +[auth.jwt] +secret = "{SECRET}" +audience = "announcer" +issuer = "https://crew.example.no" +''' + + +def test_gyldig_jwt_slipper_inn_med_rolle_og_navn(tmp_path): + c, a = build(tmp_path, JWT_CFG) + assert [p.name for p in a.providers] == ["jwt"] + t = token({"aud": "announcer", "iss": "https://crew.example.no", + "name": "Kari Nordmann", "roles": ["crew"], "sub": "42"}) + body = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).get_json() + assert body["name"] == "Kari Nordmann" + assert body["roles"] == ["crew"] + assert body["subject"] == "jwt:42" + assert body["method"] == "jwt" + + +def test_utlopt_jwt_avvises(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]}, + exp=time.time() - 3600) + r = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}) + assert r.status_code == 401 + assert "utløpt" in r.get_json()["error"] + + +def test_jwt_for_en_annen_tjeneste_avvises(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "et-annet-system", "iss": "https://crew.example.no", "roles": ["admin"]}) + r = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}) + assert r.status_code == 401 + assert "annen tjeneste" in r.get_json()["error"] + + +def test_jwt_fra_feil_utsteder_avvises(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://ondsinnet.example", "roles": ["admin"]}) + assert c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).status_code == 401 + + +def test_jwt_med_feil_signatur_avvises(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]}, + secret="en-helt-annen-hemmelighet-som-er-lang-nok-for-hmac") + assert c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).status_code == 401 + + +def test_jwt_uten_exp_avvises(tmp_path): + """Et token uten utløpstid er en evig nøkkel; den vil vi ikke ha.""" + c, _ = build(tmp_path, JWT_CFG) + body = {"sub": "u1", "aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]} + t = pyjwt.encode(body, SECRET, algorithm="HS256") + assert c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).status_code == 401 + + +def test_jwt_rolle_uten_rettighet_gir_403_ikke_401(tmp_path): + """Forskjellen betyr noe for klienten: 401 = logg inn, 403 = du får ikke.""" + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["crew"]}) + h = {"Authorization": f"Bearer {t}"} + assert c.get("/do/announce", headers=h).status_code == 200 + assert c.get("/do/clear", headers=h).status_code == 403 + + +def test_ingen_authorization_header_gir_401(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + assert c.get("/do/view").status_code == 401 + + +@pytest.mark.parametrize( + "claims, role_claim, forventet", + [ + ({"roles": ["crew", "admin"]}, "roles", ["crew", "admin"]), + ({"roles": "crew admin"}, "roles", ["crew", "admin"]), + ({"roles": "crew,admin"}, "roles", ["crew", "admin"]), + ({"roles": "crew"}, "roles", ["crew"]), + ({"realm_access": {"roles": ["crew"]}}, "realm_access.roles", ["crew"]), + ({"groups": ["admin"]}, "groups", ["admin"]), + ({}, "roles", []), + ], +) +def test_rolleclaim_tolkes_i_alle_formene(tmp_path, claims, role_claim, forventet): + c, _ = build(tmp_path, JWT_CFG + f'role_claim = "{role_claim}"\n') + t = token({"aud": "announcer", "iss": "https://crew.example.no", **claims}) + r = c.get("/do/view", headers={"Authorization": f"Bearer {t}"}) + if forventet: + assert r.get_json()["roles"] == forventet + else: + # Gyldig token uten roller: vi *vet* hvem dette er, han får bare ikke + # gjøre noe. Da er 403 riktig – å logge inn på nytt hjelper ikke. + assert r.status_code == 403 + + +def test_default_role_brukes_nar_tokenet_mangler_roller(tmp_path): + c, _ = build(tmp_path, JWT_CFG + 'default_role = "crew"\n') + t = token({"aud": "announcer", "iss": "https://crew.example.no"}) + body = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).get_json() + assert body["roles"] == ["crew"] + + +@pytest.mark.parametrize( + "claims, forventet", + [ + ({"name": "Kari"}, "Kari"), + ({"preferred_username": "kari"}, "kari"), + ({"email": "kari@example.no"}, "kari@example.no"), + ({}, "u1"), + ], +) +def test_visningsnavn_finnes_fram_fra_det_som_er(tmp_path, claims, forventet): + c, _ = build(tmp_path, JWT_CFG + 'default_role = "admin"\n') + t = token({"aud": "announcer", "iss": "https://crew.example.no", **claims}) + assert c.get("/do/view", headers={"Authorization": f"Bearer {t}"}).get_json()["name"] == forventet + + +# -------------------------------------------------------------------------- +# Token-lenka fra crew-panelet +# -------------------------------------------------------------------------- + + +def test_token_lenke_gir_sesjon_sa_resten_virker_uten_header(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://crew.example.no", + "name": "Kent", "roles": ["admin"]}) + r = c.get(f"/auth/token?t={t}") + assert r.status_code == 302 + body = c.get("/do/clear").get_json() # ingen header denne gangen + assert body["name"] == "Kent" + assert body["method"] == "jwt" + + +def test_token_lenke_med_ugyldig_token_gir_ingen_sesjon(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + assert c.get("/auth/token?t=aldeles-ugyldig").status_code == 401 + assert c.get("/do/view").status_code == 401 + + +def test_token_lenke_finnes_ikke_uten_jwt_oppsett(tmp_path): + c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n') + assert c.get("/auth/token?t=hva-som-helst").status_code == 404 + + +def test_utlogging_tommer_sesjonen(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]}) + c.get(f"/auth/token?t={t}") + assert c.get("/do/clear").status_code == 200 + c.get("/auth/logout") + assert c.get("/do/clear").status_code == 401 + + +def test_rettigheter_slas_opp_pa_nytt_for_hver_forespørsel(tmp_path): + """Endrer man [roles] skal det slå inn uten at folk må logge inn på nytt.""" + c, a = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["crew"]}) + c.get(f"/auth/token?t={t}") + assert c.get("/do/clear").status_code == 403 + a.cfg.data["roles"]["crew"] = ["*"] + assert c.get("/do/clear").status_code == 200 + + +# -------------------------------------------------------------------------- +# Flere metoder samtidig +# -------------------------------------------------------------------------- + + +def test_pin_og_jwt_kan_virke_side_om_side(tmp_path): + c, a = build(tmp_path, JWT_CFG + '\n[auth.pin]\npin = "1234"\nrole = "crew"\n') + assert sorted(p.name for p in a.providers) == ["jwt", "pin"] + assert c.get("/do/announce", headers={"X-Pin": "1234"}).status_code == 200 + t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]}) + assert c.get("/do/clear", headers={"Authorization": f"Bearer {t}"}).status_code == 200 + + +def test_rekkefolgen_i_providers_bestemmer(tmp_path): + c, a = build(tmp_path, '[auth]\nproviders = ["jwt", "pin"]\n' + JWT_CFG + '\n[auth.pin]\npin = "1"\n') + assert [p.name for p in a.providers] == ["jwt", "pin"] + + +# -------------------------------------------------------------------------- +# Enhetstester på småtingene +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value, forventet", + [ + (["a", "b"], ("a", "b")), + ("a b", ("a", "b")), + ("a,b", ("a", "b")), + ("a", ("a",)), + (None, ()), + (42, ()), + ([" a ", ""], ("a",)), + ], +) +def test_roller_tolkes(value, forventet): + assert auth._roles(value) == forventet + + +def test_nestet_claim_hentes(): + claims = {"realm_access": {"roles": ["crew"]}} + assert auth._claim(claims, "realm_access.roles") == ["crew"] + assert auth._claim(claims, "finnes.ikke") is None + assert auth._claim(claims, "realm_access.roles.dypere") is None + + +def test_rolle_til_handling(): + cfg = config.load("/finnes-ikke.toml") + assert cfg.permissions(("admin",)) == frozenset(config.ACTIONS) + assert cfg.permissions(("crew",)) == frozenset({"view", "preview", "announce"}) + assert cfg.permissions(("ukjent-rolle",)) == frozenset() + assert cfg.permissions(()) == frozenset() + # Roller legges sammen + cfg.data["roles"]["bare_stopp"] = ["stop"] + assert cfg.permissions(("crew", "bare_stopp")) == frozenset({"view", "preview", "announce", "stop"}) + + +def test_ukjente_handlinger_i_roles_ignoreres(): + cfg = config.load("/finnes-ikke.toml") + cfg.data["roles"]["rar"] = ["announce", "slett-alt"] + assert cfg.permissions(("rar",)) == frozenset({"announce"}) + + +def test_sesjonsnokkel_er_stabil_mellom_omstarter(tmp_path): + cfg = config.load("/finnes-ikke.toml") + cfg.data["paths"]["state"] = str(tmp_path) + first = auth.secret_key(cfg) + assert auth.secret_key(cfg) == first + assert (tmp_path / "secret_key").stat().st_mode & 0o777 == 0o600 + + +# -------------------------------------------------------------------------- +# OIDC +# -------------------------------------------------------------------------- +# Crew-panelet har ikke OIDC-endepunkter ennå, så her står en stubbet +# identitetsleverandør i stedet. Det er HTTP-kallene som stubbes; discovery, +# PKCE, state-sjekken, kodebyttet og verifiseringen av id_token er vår egen +# kode og kjøres på ordentlig. + +OIDC_CFG = ''' +[auth.oidc] +issuer = "https://crew.example.no" +client_id = "announcer" +client_secret = "panel-hemmelighet-som-er-lang-nok-for-hmac-sha256" +''' + +DISCOVERY = { + "issuer": "https://crew.example.no", + "authorization_endpoint": "https://crew.example.no/oauth/authorize", + "token_endpoint": "https://crew.example.no/oauth/token", + "jwks_uri": "", +} + + +@pytest.fixture +def idp(monkeypatch): + """Stubber HTTP-laget og husker hva vi sendte til tokenendepunktet.""" + state: dict = {"posts": [], "claims": {}} + + def fake_get(url, timeout=10): + assert url == "https://crew.example.no/.well-known/openid-configuration" + return dict(DISCOVERY) + + def fake_post(url, form, headers=None, timeout=10): + state["posts"].append({"url": url, "form": form, "headers": headers or {}}) + claims = { + "iss": "https://crew.example.no", + "aud": "announcer", + "exp": time.time() + 600, + "sub": "142", + **state["claims"], + } + # Panelet signerer id_token med client_secret (HS256) i dette oppsettet. + return {"id_token": pyjwt.encode(claims, "panel-hemmelighet-som-er-lang-nok-for-hmac-sha256", algorithm="HS256")} + + monkeypatch.setattr(auth, "_get_json", fake_get) + monkeypatch.setattr(auth, "_post_json", fake_post) + return state + + +def test_oidc_login_sender_brukeren_til_panelet_med_pkce(tmp_path, idp): + c, _ = build(tmp_path, OIDC_CFG) + r = c.get("/auth/login") + assert r.status_code == 302 + url = urllib.parse.urlparse(r.headers["Location"]) + q = urllib.parse.parse_qs(url.query) + assert url.netloc == "crew.example.no" and url.path == "/oauth/authorize" + assert q["response_type"] == ["code"] + assert q["client_id"] == ["announcer"] + assert q["code_challenge_method"] == ["S256"] + assert q["redirect_uri"][0].endswith("/auth/callback") + assert len(q["code_challenge"][0]) == 43 # sha256, base64url uten padding + assert q["state"][0] + + +def test_oidc_hele_runden_gir_innlogget_sesjon(tmp_path, idp): + idp["claims"] = {"name": "Kari Nordmann", "roles": ["crew"]} + c, _ = build(tmp_path, OIDC_CFG) + q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query) + r = c.get(f"/auth/callback?code=en-kode&state={q['state'][0]}") + assert r.status_code == 302 and r.headers["Location"] == "/" + + body = c.get("/do/announce").get_json() + assert body["name"] == "Kari Nordmann" + assert body["roles"] == ["crew"] + assert body["subject"] == "oidc:142" + assert body["method"] == "oidc" + assert c.get("/do/clear").status_code == 403 + + # Kodebyttet: PKCE-verifier med, hemmeligheten som Basic-auth, ikke i body. + post = idp["posts"][0] + assert post["url"] == "https://crew.example.no/oauth/token" + assert post["form"]["grant_type"] == "authorization_code" + assert post["form"]["code"] == "en-kode" + assert len(post["form"]["code_verifier"]) > 40 + assert "client_secret" not in post["form"] + assert post["headers"]["Authorization"].startswith("Basic ") + + +def test_oidc_callback_med_feil_state_avvises(tmp_path, idp): + """Uten denne sjekken kan en angriper få deg til å logge inn som seg selv.""" + c, _ = build(tmp_path, OIDC_CFG) + c.get("/auth/login") + r = c.get("/auth/callback?code=en-kode&state=tilfeldig-tull") + assert r.status_code == 400 + assert c.get("/do/view").status_code == 401 + + +def test_oidc_callback_uten_forutgaende_login_avvises(tmp_path, idp): + c, _ = build(tmp_path, OIDC_CFG) + assert c.get("/auth/callback?code=x&state=y").status_code == 400 + + +def test_oidc_feil_fra_panelet_vises(tmp_path, idp): + c, _ = build(tmp_path, OIDC_CFG) + c.get("/auth/login") + r = c.get("/auth/callback?error=access_denied&error_description=Avvist+av+bruker") + assert r.status_code == 401 + assert "access_denied" in r.get_json()["error"] + + +def test_oidc_id_token_for_en_annen_klient_avvises(tmp_path, idp, monkeypatch): + c, _ = build(tmp_path, OIDC_CFG) + monkeypatch.setattr(auth, "_post_json", lambda url, form, headers=None, timeout=10: { + "id_token": pyjwt.encode( + {"iss": "https://crew.example.no", "aud": "et-annet-system", + "exp": time.time() + 600, "sub": "1"}, + "panel-hemmelighet-som-er-lang-nok-for-hmac-sha256", algorithm="HS256")}) + q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query) + assert c.get(f"/auth/callback?code=k&state={q['state'][0]}").status_code == 401 + + +def test_oidc_state_kan_ikke_brukes_to_ganger(tmp_path, idp): + c, _ = build(tmp_path, OIDC_CFG) + q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query) + state = q["state"][0] + assert c.get(f"/auth/callback?code=k&state={state}").status_code == 302 + c.get("/auth/logout") + assert c.get(f"/auth/callback?code=k&state={state}").status_code == 400 + + +def test_oidc_manuelle_endepunkter_hopper_over_discovery(tmp_path, monkeypatch): + """Panelet har ikke .well-known ennå, så dette er veien dit først.""" + def nei(*a, **kw): + raise AssertionError("skulle ikke slått opp discovery") + monkeypatch.setattr(auth, "_get_json", nei) + c, _ = build(tmp_path, OIDC_CFG + ''' +authorization_endpoint = "https://crew.example.no/oauth/authorize" +token_endpoint = "https://crew.example.no/oauth/token" +''') + assert c.get("/auth/login").status_code == 302 + + +def test_oidc_login_finnes_ikke_uten_oppsett(tmp_path): + c, _ = build(tmp_path, '[auth.pin]\npin = "1"\n') + assert c.get("/auth/login").status_code == 404 + assert c.get("/auth/callback?code=x&state=y").status_code == 404 + + +def test_public_url_styrer_redirect_uri(tmp_path, idp): + """Bak en omvendt proxy må redirect_uri peke utover, ikke på localhost.""" + c, _ = build(tmp_path, '[server]\npublic_url = "https://utrop.example.no"\n' + OIDC_CFG) + q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query) + assert q["redirect_uri"] == ["https://utrop.example.no/auth/callback"] diff --git a/uv.lock b/uv.lock index 0c62a02..2de02a3 100644 --- a/uv.lock +++ b/uv.lock @@ -146,6 +146,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "announcer" +version = "0.2.0" +source = { virtual = "." } +dependencies = [ + { name = "edge-tts" }, + { name = "flask" }, + { name = "piper-tts" }, + { name = "pyjwt", extra = ["crypto"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "edge-tts", specifier = ">=7.2.8" }, + { name = "flask", specifier = ">=3.1.3" }, + { name = "piper-tts", specifier = ">=1.8.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.3" }] + [[package]] name = "attrs" version = "26.1.0" @@ -173,6 +200,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -182,6 +282,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + [[package]] name = "edge-tts" version = "7.2.8" @@ -304,6 +476,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -665,6 +846,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/9c/c736d1961cf9ce0655278731b9430df41892ca2ded7b09dabcb340612158/piper_tts-1.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:5da9bfdb05dfe15da3536859d422e605483ffa6d2b3ec2c5b9593bae6b5aa6a4", size = 34119688, upload-time = "2026-09-04T16:47:28.339Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "propcache" version = "0.5.4" @@ -775,20 +965,51 @@ wheels = [ ] [[package]] -name = "sg-announcer" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "edge-tts" }, - { name = "flask" }, - { name = "piper-tts" }, +name = "pycparser" +version = "3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/d3/eb1d3bc30dda12f7e69640ae2ac8cb10240b71fb73024ad528b7d2ae73da/pycparser-3.1.tar.gz", hash = "sha256:b3fc6dec06a8b2fefa0ed4ff92285306a5e3be9987bc5603c9edbdc4e492418f", size = 105946, upload-time = "2026-10-08T12:26:34.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/ce/b3ae9ee0324d991c860187be2a6ee436d27a3d02397eaddbb101ec901f3d/pycparser-3.1-py3-none-any.whl", hash = "sha256:f09d358c840bd147b79e55f2bc494f18ea869dc897f5852a8f5766b74f787882", size = 48709, upload-time = "2026-10-08T12:26:32.97Z" }, ] -[package.metadata] -requires-dist = [ - { name = "edge-tts", specifier = ">=7.2.8" }, - { name = "flask", specifier = ">=3.1.3" }, - { name = "piper-tts", specifier = ">=1.8.0" }, +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]]