Pluggbar autentisering: PIN, JWT og OIDC

Dagens «auth» var én delt PIN i et X-Pin-header, sammenlignet med !=, og
den sa ingenting om hvem som sendte et utrop – køen viste bare IP-adressen.
Crew har et eget adminpanel som skal bli identitetsleverandør.

Modellen er én datatype og én funksjon: Principal (hvem + roller +
rettigheter) og require(action), som enten gir deg den eller stopper
forespørselen. Handlinger: view, preview, announce, stop, clear.

Tre leverandører, prøvd i rekkefølge, alle valgfrie:

- pin   X-Pin som før, men med hmac.compare_digest istedenfor ==. Rolle
        admin som standard, så eksisterende oppsett oppfører seg likt.
        Beholdt bevisst: den virker uten nett, og det er nettopp da man
        trenger å rope ut noe.
- jwt   Authorization: Bearer. HS256 med delt hemmelighet, eller
        RS256/ES256 mot panelets JWKS. Krever exp – et token uten
        utløpstid er en evig nøkkel. Roller fra et konfigurerbart claim,
        som tåler liste, streng, kommaliste og punktsti.
- oidc  Autorisasjonskode med PKCE. 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,
slik det har virket til nå. Er ingen aktiv, er alt åpent – som før.

/auth/token?t=<jwt> er broa mens panelet mangler OIDC: panelet lenker hit
med et kortlevd token, vi setter sesjonscookie. Ett klikk, ingen PIN.
Kontrakten panelet skal oppfylle står i docs/crew-integration.md.

401 betyr «vi vet ikke hvem du er, logg inn», 403 «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.

Samtidig:

- /api/status er lukket bak «view». Den var åpen, og lekket både
  meldingstekstene og IP-ene til alle som hadde sendt noe, til hvem som
  helst som nådde porten. IP vises nå bare for den som har «clear»; alle
  andre ser navnet.
- Køen og en revisjonslogg i logs/announcements.jsonl viser hvem som sendte
  hva. Loggen feiler aldri forespørselen – en logg som ikke lar seg skrive
  skal ikke hindre et utrop.
- Branding flyttet fra index.html til /api/config, så config.toml fra
  forrige commit faktisk brukes.
- Sesjonsnøkkel i instance/secret_key, ellers logges alle ut ved hver
  restart. SESSION_COOKIE_SECURE settes bare bak TLS: appen kjører på rå
  HTTP over LAN og NetBird, og med Secure på ville ingen klart å logge inn.

57 tester, de fleste på auth, inkludert hele OIDC-runden mot en stubbet
leverandør. Det er delen 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-08 15:43:35 +02:00
parent 87066bb8c2
commit d6eb811ee5
10 changed files with 2090 additions and 117 deletions

141
AGENTS.md
View file

@ -1,39 +1,130 @@
# 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` |
## 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.
## 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 <jwt>` 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=<jwt>` 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:<stem>`; ffmpeg kutter til 6 s, normaliserer
@ -65,6 +156,16 @@ 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 man får røde tester med feilmeldinger som ikke peker
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.

View file

@ -1,16 +1,19 @@
#!/usr/bin/env python3
"""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) eller paplay.
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)

642
auth.py Normal file
View file

@ -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 <jwt> 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

204
docs/crew-integration.md Normal file
View file

@ -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:<sub>` |
| `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://<announcer>:8090/auth/token?t=<jwt>
```
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://<vert>: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.

View file

@ -17,6 +17,11 @@
h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: .02em; }
h1 span { color: var(--accent); }
.sub { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
.top { display: flex; gap: 12px; align-items: flex-start; justify-content: space-between; }
.who { font-size: 13px; color: var(--muted); text-align: right; white-space: nowrap; padding-top: 2px; }
.who b { color: var(--fg); font-weight: 600; }
.who a { color: var(--accent); text-decoration: none; }
.who a:hover { text-decoration: underline; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: .06em; }
textarea { width: 100%; min-height: 120px; resize: vertical; background: var(--panel2); color: var(--fg);
@ -27,8 +32,6 @@
.row > div { flex: 1 1 160px; }
select, input[type=range] { width: 100%; }
select { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; }
.check { display: flex; align-items: center; gap: 8px; padding: 10px 0; font-size: 15px; }
.check input { width: 18px; height: 18px; accent-color: var(--accent); }
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.chip { background: var(--panel2); border: 1px solid var(--line); color: var(--fg); border-radius: 999px;
padding: 6px 12px; font-size: 13px; cursor: pointer; }
@ -39,6 +42,7 @@
button.btn:disabled { opacity: .5; cursor: default; }
button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
button.danger { background: transparent; color: var(--danger); border-color: var(--danger); flex: 0 1 auto; }
a.btn { text-decoration: none; display: inline-block; text-align: center; }
audio { width: 100%; margin-top: 12px; display: none; }
audio.show { display: block; }
.msg { margin-top: 10px; font-size: 14px; min-height: 1.4em; }
@ -47,32 +51,44 @@
.status-head h2 { font-size: 14px; margin: 0; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
.now { padding: 12px; border-radius: 8px; background: var(--panel2); border-left: 4px solid var(--ok); }
.now.idle { border-left-color: var(--line); color: var(--muted); }
.now.idle::before { content: "Stille · ingenting i kø"; }
ul.list { list-style: none; padding: 0; margin: 8px 0 0; }
ul.list li { padding: 8px 0; border-top: 1px solid var(--line); font-size: 14px; display: flex; gap: 10px; }
ul.list li .t { color: var(--muted); min-width: 48px; font-variant-numeric: tabular-nums; }
ul.list li .s { margin-left: auto; color: var(--muted); font-size: 12px; white-space: nowrap; }
ul.list li .by { color: var(--muted); font-size: 12px; }
ul.list li.failed .s { color: var(--danger); } ul.list li.stopped .s { color: var(--warn); }
.pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--ok); margin-right: 8px;
animation: pulse 1.2s infinite; vertical-align: middle; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .25 } }
#pinbox { display: none; }
#loginbox, #pinbox { display: none; }
#pinbox input { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; width: 100%; }
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
.hidden { display: none !important; }
</style>
</head>
<body>
<main>
<h1><span>Announcer</span></h1>
<p class="sub">Skriv meldingen, forhåndslytt, og send den ut på anlegget.</p>
<div class="top">
<div>
<h1 id="title"></h1>
<p class="sub" id="subtitle"></p>
</div>
<div class="who" id="who"></div>
</div>
<div class="card" id="loginbox">
<div id="loginbtns" class="actions" style="margin-top:0"></div>
</div>
<div class="card" id="pinbox">
<label for="pin">PIN</label>
<input id="pin" type="password" inputmode="numeric" placeholder="Spør i crew-rommet">
<label for="pin" id="pinlabel">PIN</label>
<input id="pin" type="password" inputmode="numeric" autocomplete="current-password">
<div class="hint" id="pinhint"></div>
</div>
<div class="card">
<label for="text">Melding</label>
<textarea id="text" placeholder="F.eks. «Pizzaen er kommet. Hent i kantina!»" autofocus></textarea>
<textarea id="text" autofocus></textarea>
<div class="counter"><span id="count">0</span> / <span id="max">600</span></div>
<div class="chips" id="chips"></div>
@ -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 = `<span class="t">${hhmm(it.created)}</span><span class="x"></span><span class="s">${STATUS[it.status] || it.status}${it.error ? ' · ' + it.error : ''}</span>`;
l.innerHTML = `<span class="t"></span><span class="x"></span><span class="by"></span><span class="s"></span>`;
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 = '<span class="pulse"></span>'; 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();
</script>

View file

@ -7,6 +7,8 @@
# 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
@ -20,6 +22,7 @@ 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/$APP.service"
@ -46,7 +49,7 @@ 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()"
@ -79,8 +82,16 @@ UNIT
else
echo "# Environment=ANNOUNCER_PIN=1234"
fi
if [[ -n "$TITLE" ]]; then
echo "Environment=ANNOUNCER_TITLE=$TITLE"
else
echo "# Environment=ANNOUNCER_TITLE=Utrop"
fi
cat <<'UNIT'
# Environment=ANNOUNCER_SINK=alsa_output.xxx
# Hemmeligheter hører hjemme her og ikke i config.toml:
# Environment=ANNOUNCER_JWT_SECRET=...
# Environment=ANNOUNCER_OIDC_CLIENT_SECRET=...
Restart=on-failure
RestartSec=3
@ -88,7 +99,7 @@ RestartSec=3
WantedBy=default.target
UNIT
} > "$UNIT"
chmod 600 "$UNIT" # PIN står i klartekst her
chmod 600 "$UNIT" # PIN og nøkler står i klartekst her
systemctl --user daemon-reload
systemctl --user enable --now "$APP"
systemctl --user restart "$APP"
@ -120,6 +131,10 @@ cat <<MSG
Åpne http://${IP:-<denne-maskinens-ip>}:$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).

View file

@ -1,12 +1,18 @@
[project]
name = "announcer"
version = "0.1.0"
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",
]
[dependency-groups]
dev = [
"pytest>=8.3",
]
[tool.uv]

22
tests/conftest.py Normal file
View file

@ -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)

535
tests/test_auth.py Normal file
View file

@ -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/<action>")
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"]

223
uv.lock generated
View file

@ -148,12 +148,18 @@ wheels = [
[[package]]
name = "announcer"
version = "0.1.0"
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]
@ -161,8 +167,12 @@ 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"
@ -190,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"
@ -199,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"
@ -321,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"
@ -682,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"
@ -791,6 +964,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" },
]
[[package]]
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]]
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]]
name = "tabulate"
version = "0.10.0"