diff --git a/AGENTS.md b/AGENTS.md index afb4c95..8a4cab4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` fra crew-panelet. HS256 med delt + hemmelighet, eller RS256/ES256 mot panelets JWKS (PyJWT cacher nøklene). + Krever `exp`; roller leses av et konfigurerbart claim. +- **oidc** – autorisasjonskode med PKCE. `/auth/login` → panelet → + `/auth/callback`. Discovery hentes først ved første innlogging, så tjenesten + starter selv om panelet er nede. + +**En leverandør er aktiv når den står i `auth.providers` *eller* når den er +ferdig konfigurert.** Derfor slår `ANNOUNCER_PIN=1234` alene fortsatt på PIN. +Er ingen aktiv, er alt åpent. + +`/auth/token?t=` er broa mens panelet mangler OIDC: panelet lenker hit med +et kortlevd token, announcer setter sesjonscookie. Se +`docs/crew-integration.md` – det er kontrakten panelet skal oppfylle. + +Roller → handlinger står i `[roles]` i `config.toml`. `"*"` betyr alt. +Rettighetene slås opp på nytt per forespørsel, så en endring der slår inn uten +at noen må logge inn igjen; nye `[auth.*]`-verdier krever omstart. + +401 betyr «vi vet ikke hvem du er, logg inn», 403 betyr «vi vet hvem du er, men +rollen din får ikke dette». Et gyldig token uten roller gir 403, ikke 401 – å +logge inn på nytt ville ikke hjulpet. + +Sesjonsnøkkelen genereres én gang og lagres i `instance/secret_key` (0600). +Uten den logges alle ut ved hver `systemctl restart`. `SESSION_COOKIE_SECURE` +settes bare når `server.behind_tls` er sann – appen kjører på rå HTTP over LAN +og NetBird, og med Secure på ville cookien aldri blitt sendt. ## Hvordan det henger sammen +- `/api/config` er alt sida trenger for å tegne seg selv: branding, stemmer, + jingler og en `auth`-blokk med hvem du er og hva du får gjøre. Svarer alltid + 200, også med feil PIN – klienten spør «hvem er jeg?», og «ikke innlogget» er + et svar, ikke en feil. Route-funksjonen heter `client_config()`; navnet + `config` er tatt av modulen. - `/api/preview` syntetiserer og returnerer WAV til nettleseren. Ingen lyd på PC-en. -- `/api/announce` legger meldingen i én FIFO-kø; en bakgrunnstråd spiller én - om gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear` +- `/api/announce` legger meldingen i én FIFO-kø; en bakgrunnstråd spiller én om + gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear` tømmer køen. +- `/api/status` krever `view`. Den var åpen før, og lekket både meldingstekster + og IP-ene til dem som hadde sendt noe. IP vises nå bare for den som har + `clear`; alle andre ser navnet. +- `/` er åpen uansett, ellers kunne ingen komme til innloggingsknappen. - Knappen «Send ut» er låst til nøyaktig den teksten/stemmen/hastigheten som sist ble forhåndslyttet. Det er med vilje: ingen skal sende noe de ikke har hørt. - Ferdige WAV-er bufres i `cache/` etter innholdshash. Trygt å slette. +- `audit()` skriver én JSON-linje per hendelse til `logs/announcements.jsonl` + (`announce`, `played`, `stop`, `clear`) med navn, subject, metode og IP. + Rullerer til `.jsonl.1` ved 5 MB. Den feiler aldri forespørselen – en logg + som ikke lar seg skrive skal ikke hindre et utrop. - Jingler: de innebygde (`BUILTIN_CHIMES`) genereres i Python med `_tone`/ `_mix`/`_overlay`, ingen lydfiler. Egne filer i `chimes/` (wav/mp3/ogg/flac) dukker opp i menyen som `file:`; ffmpeg kutter til 6 s, normaliserer @@ -65,6 +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. diff --git a/announcer.py b/announcer.py index 6a98af8..19f37c2 100644 --- a/announcer.py +++ b/announcer.py @@ -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) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..11975f6 --- /dev/null +++ b/auth.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +"""Pluggbar autentisering for announcer. + +Hele modellen er én datatype og én funksjon: + + Principal – hvem dette er, hvilke roller, hva rollene får gjøre + require(action) – hent den, eller stopp forespørselen med 401/403 + +Rundt det ligger leverandører som prøves i rekkefølge. Hver av dem svarer på +én forespørsel med «dette er brukeren», «jeg fant ingen legitimasjon av min +type» (None) eller AuthError («fant noe, men det var ugyldig»): + + pin – X-Pin-header, én delt kode. Virker uten nett, derfor beholdt. + jwt – Authorization: Bearer fra crew-panelet. HS256 med delt + hemmelighet, eller RS256/ES256 mot panelets JWKS. + oidc – autorisasjonskode med PKCE mot crew-panelet; nettleseren logger + inn der og får en sesjonscookie her. + +Er ingen leverandør konfigurert, er alt åpent – nøyaktig som før. Det er med +vilje: tjenesten skal kunne settes opp på en kveld uten identitetsleverandør. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import logging +import json +import secrets +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from flask import Blueprint, abort, g, redirect, request, session, url_for + +from config import ACTIONS, Config + +log = logging.getLogger("announcer.auth") + +SESSION_KEY = "principal" +NAME_CLAIMS = ("name", "preferred_username", "nickname", "given_name", "email") + + +class AuthError(Exception): + """Legitimasjon fulgte med, men den holdt ikke.""" + + +# -------------------------------------------------------------------------- +# Principal +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Principal: + subject: str # stabil id: "pin", "jwt:4f2a", "oidc:kent" + name: str # vises i køen og i loggen + roles: tuple[str, ...] + method: str # none | anonymous | pin | jwt | oidc + permissions: frozenset[str] + + @property + def anonymous(self) -> bool: + return self.method in ("none", "anonymous") + + def can(self, action: str) -> bool: + return action in self.permissions + + def public(self) -> dict[str, Any]: + return { + "subject": self.subject, + "name": self.name, + "roles": list(self.roles), + "method": self.method, + "can": sorted(self.permissions), + } + + +def _claim(claims: dict, path: str) -> Any: + """Henter et claim, også nestet: "realm_access.roles".""" + cur: Any = claims + for part in path.split("."): + if not isinstance(cur, dict): + return None + cur = cur.get(part) + return cur + + +def _roles(value: Any) -> tuple[str, ...]: + """Roller kommer som liste, streng eller mellomromsdelt streng.""" + if isinstance(value, str): + return tuple(v for v in value.replace(",", " ").split() if v) + if isinstance(value, (list, tuple, set)): + return tuple(str(v).strip() for v in value if str(v).strip()) + return () + + +def _display_name(claims: dict, name_claim: str) -> str: + if name_claim: + value = _claim(claims, name_claim) + if value: + return str(value) + for key in NAME_CLAIMS: + if claims.get(key): + return str(claims[key]) + return str(claims.get("sub") or "ukjent") + + +# -------------------------------------------------------------------------- +# JWT-verifisering, delt av jwt- og oidc-leverandøren +# -------------------------------------------------------------------------- + + +class TokenVerifier: + """Verifiserer signatur, iss, aud og exp. JWKS caches av PyJWT selv.""" + + def __init__(self, section: dict[str, Any]) -> None: + self.issuer = str(section.get("issuer", "")).strip() + self.audience = str(section.get("audience", "")).strip() + self.secret = str(section.get("secret", "")).strip() + self.jwks_url = str(section.get("jwks_url", "")).strip() + self.leeway = int(section.get("leeway", 30)) + self.verify_signature = bool(section.get("verify_signature", True)) + # Tom liste: utled av hvordan nøkkelen kommer. Delt hemmelighet er + # symmetrisk (HS256), JWKS er asymmetrisk (RS256/ES256). + algs = [str(a) for a in section.get("algorithms") or []] + self.algorithms = algs or (["RS256", "ES256"] if self.jwks_url else ["HS256"]) + self._jwks: Any = None + + def _signing_key(self, token: str) -> Any: + if not self.jwks_url: + return self.secret + if self._jwks is None: + import jwt # lazy: PIN-oppsett skal ikke trenge pyjwt + + self._jwks = jwt.PyJWKClient(self.jwks_url, cache_keys=True, lifespan=3600) + return self._jwks.get_signing_key_from_jwt(token).key + + def verify(self, token: str) -> dict: + import jwt + + if not self.verify_signature: + # Bare for et crew-panel bak en proxy som alt har validert tokenet. + return jwt.decode(token, options={"verify_signature": False}) + if not (self.secret or self.jwks_url): + raise AuthError("JWT er ikke konfigurert") + try: + return jwt.decode( + token, + self._signing_key(token), + algorithms=self.algorithms, + audience=self.audience or None, + issuer=self.issuer or None, + leeway=self.leeway, + options={ + "verify_aud": bool(self.audience), + "verify_iss": bool(self.issuer), + "require": ["exp"], + }, + ) + except jwt.ExpiredSignatureError as e: + raise AuthError("Tokenet er utløpt") from e + except jwt.InvalidAudienceError as e: + raise AuthError("Tokenet gjelder en annen tjeneste") from e + except jwt.InvalidTokenError as e: + raise AuthError(f"Ugyldig token ({e})") from e + except urllib.error.URLError as e: + raise AuthError(f"Fikk ikke hentet nøkler fra {self.jwks_url} ({e})") from e + + +# -------------------------------------------------------------------------- +# Leverandører +# -------------------------------------------------------------------------- + + +class Provider: + name = "" + + def authenticate(self, req) -> Principal | None: # noqa: ANN001 + return None + + def describe(self) -> dict[str, Any]: + return {"method": self.name} + + +class PinProvider(Provider): + name = "pin" + + def __init__(self, cfg: Config) -> None: + s = cfg.section("auth.pin") + self.pin = str(s.get("pin", "")).strip() + self.role = str(s.get("role") or "admin") + self.label = str(s.get("label") or "PIN") + self.hint = str(s.get("hint", "")) + self.cfg = cfg + + def authenticate(self, req) -> Principal | None: # noqa: ANN001 + given = req.headers.get("X-Pin", "").strip() + if not given: + auth = req.headers.get("Authorization", "") + if auth[:4].lower() == "pin ": + given = auth[4:].strip() + if not given: + return None + # compare_digest, ikke ==, så svartiden ikke lekker hvor langt man kom. + if not hmac.compare_digest(given, self.pin): + raise AuthError("Feil PIN") + roles = (self.role,) + # En delt kode peker ikke på en person, og skal ikke late som. Vil man + # vite hvem som sendte hva, er det JWT eller OIDC man vil ha. + return Principal("pin", "delt PIN", roles, "pin", self.cfg.permissions(roles)) + + def describe(self) -> dict[str, Any]: + return {"method": "pin", "label": self.label, "hint": self.hint} + + +class JwtProvider(Provider): + name = "jwt" + + def __init__(self, cfg: Config) -> None: + s = cfg.section("auth.jwt") + self.cfg = cfg + self.verifier = TokenVerifier(s) + self.role_claim = str(s.get("role_claim") or "roles") + self.name_claim = str(s.get("name_claim", "")) + self.default_role = str(s.get("default_role", "")) + self.session_seconds = int(s.get("session_seconds", 43200)) + + def principal(self, claims: dict, method: str = "jwt") -> Principal: + roles = _roles(_claim(claims, self.role_claim)) + if not roles and self.default_role: + roles = (self.default_role,) + sub = str(claims.get("sub") or claims.get("email") or "ukjent") + return Principal( + f"{method}:{sub}", + _display_name(claims, self.name_claim), + roles, + method, + self.cfg.permissions(roles), + ) + + def authenticate(self, req) -> Principal | None: # noqa: ANN001 + auth = req.headers.get("Authorization", "") + if auth[:7].lower() != "bearer ": + return None + token = auth[7:].strip() + if not token: + return None + return self.principal(self.verifier.verify(token)) + + +class OidcProvider(Provider): + """Autorisasjonskode med PKCE. Innloggingen skjer i nettleseren, så + authenticate() finner aldri noe i en header – sesjonen tar seg av resten.""" + + name = "oidc" + + def __init__(self, cfg: Config) -> None: + s = cfg.section("auth.oidc") + self.cfg = cfg + self.issuer = str(s.get("issuer", "")).strip().rstrip("/") + self.client_id = str(s.get("client_id", "")).strip() + self.client_secret = str(s.get("client_secret", "")).strip() + self.scopes = [str(x) for x in s.get("scopes") or ["openid"]] + self.label = str(s.get("label") or "Logg inn") + self.role_claim = str(s.get("role_claim") or "roles") + self.name_claim = str(s.get("name_claim", "")) + self.default_role = str(s.get("default_role", "")) + self._manual = { + "authorization_endpoint": str(s.get("authorization_endpoint", "")).strip(), + "token_endpoint": str(s.get("token_endpoint", "")).strip(), + "jwks_uri": str(s.get("jwks_url", "")).strip(), + "end_session_endpoint": str(s.get("end_session_endpoint", "")).strip(), + } + self._meta: dict[str, Any] | None = None + self._verifier: TokenVerifier | None = None + + # -- oppdagelse ------------------------------------------------------- + def metadata(self) -> dict[str, Any]: + """Discovery hentes først ved første innlogging, aldri ved oppstart – + tjenesten skal starte selv om crew-panelet er nede.""" + if self._meta is not None: + return self._meta + meta = {k: v for k, v in self._manual.items() if v} + if not (meta.get("authorization_endpoint") and meta.get("token_endpoint")): + url = f"{self.issuer}/.well-known/openid-configuration" + try: + fetched = _get_json(url) + except Exception as e: # noqa: BLE001 + raise AuthError(f"Fikk ikke hentet OIDC-oppsett fra {url} ({e})") from e + meta = {**fetched, **meta} + self._meta = meta + return meta + + def verifier(self) -> TokenVerifier: + if self._verifier is None: + meta = self.metadata() + self._verifier = TokenVerifier( + { + "issuer": meta.get("issuer") or self.issuer, + "audience": self.client_id, + "secret": self.client_secret, + "jwks_url": meta.get("jwks_uri", ""), + "algorithms": [], + "verify_signature": bool( + self.cfg.get("auth.oidc", "verify_signature", True) + ), + } + ) + return self._verifier + + # -- flyten ----------------------------------------------------------- + def redirect_uri(self) -> str: + base = str(self.cfg.get("server", "public_url", "")).strip().rstrip("/") + if base: + return f"{base}/auth/callback" + return url_for("auth.callback", _external=True) + + def authorize_url(self) -> str: + verifier = secrets.token_urlsafe(64) + state = secrets.token_urlsafe(24) + challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) + .rstrip(b"=") + .decode() + ) + session["oidc_state"] = state + session["oidc_verifier"] = verifier + params = { + "response_type": "code", + "client_id": self.client_id, + "redirect_uri": self.redirect_uri(), + "scope": " ".join(self.scopes), + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + } + endpoint = self.metadata()["authorization_endpoint"] + sep = "&" if "?" in endpoint else "?" + return f"{endpoint}{sep}{urllib.parse.urlencode(params)}" + + def exchange(self, code: str, verifier: str) -> dict: + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.redirect_uri(), + "client_id": self.client_id, + "code_verifier": verifier, + } + headers = {} + if self.client_secret: + basic = base64.b64encode( + f"{self.client_id}:{self.client_secret}".encode() + ).decode() + headers["Authorization"] = f"Basic {basic}" + try: + return _post_json(self.metadata()["token_endpoint"], data, headers) + except Exception as e: # noqa: BLE001 + raise AuthError(f"Kodebyttet feilet ({e})") from e + + def principal(self, claims: dict) -> Principal: + roles = _roles(_claim(claims, self.role_claim)) + if not roles and self.default_role: + roles = (self.default_role,) + sub = str(claims.get("sub") or claims.get("email") or "ukjent") + return Principal( + f"oidc:{sub}", + _display_name(claims, self.name_claim), + roles, + "oidc", + self.cfg.permissions(roles), + ) + + def describe(self) -> dict[str, Any]: + return {"method": "oidc", "label": self.label, "login_url": "/auth/login"} + + +PROVIDERS = {"pin": PinProvider, "jwt": JwtProvider, "oidc": OidcProvider} + + +# -------------------------------------------------------------------------- +# HTTP-hjelpere (stdlib, så vi slipper en ekstra avhengighet) +# -------------------------------------------------------------------------- + + +def _get_json(url: str, timeout: int = 10) -> dict: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 + return json.loads(r.read().decode()) + + +def _post_json(url: str, form: dict, headers: dict | None = None, timeout: int = 10) -> dict: + body = urllib.parse.urlencode(form).encode() + req = urllib.request.Request( + url, + data=body, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + **(headers or {}), + }, + ) + with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 + return json.loads(r.read().decode()) + + +# -------------------------------------------------------------------------- +# Authenticator +# -------------------------------------------------------------------------- + + +class Authenticator: + def __init__(self, cfg: Config) -> None: + self.cfg = cfg + self.providers: list[Provider] = [ + PROVIDERS[name](cfg) for name in cfg.active_providers() + ] + self.enabled = bool(self.providers) + + def provider(self, name: str) -> Provider | None: + return next((p for p in self.providers if p.name == name), None) + + # -- anonym ----------------------------------------------------------- + def anonymous(self) -> Principal: + """Uten konfigurerte leverandører får anonyme alt – dagens oppførsel. + + Med leverandører får de det [roles].anonymous sier, som er ingenting + som standard. Sett den til ["view"] om statusskjermen skal henge på + veggen uten innlogging.""" + if not self.enabled: + # Tomt navn: da står køen og loggen som før, uten en «bruker» som + # i praksis bare betyr «hvem som helst». + return Principal("anonymous", "", ("anonymous",), "none", frozenset(ACTIONS)) + roles = ("anonymous",) + return Principal("anonymous", "ikke innlogget", roles, "anonymous", self.cfg.permissions(roles)) + + # -- sesjon ----------------------------------------------------------- + def from_session(self) -> Principal | None: + raw = session.get(SESSION_KEY) + if not isinstance(raw, dict): + return None + if raw.get("exp") and float(raw["exp"]) < time.time(): + session.pop(SESSION_KEY, None) + return None + roles = tuple(str(r) for r in raw.get("roles", ())) + return Principal( + str(raw.get("subject", "?")), + str(raw.get("name", "?")), + roles, + str(raw.get("method", "jwt")), + # Rettigheter slås opp på nytt hver gang, så en endring i + # [roles] slår inn uten at folk må logge inn igjen. + self.cfg.permissions(roles), + ) + + def remember(self, p: Principal, seconds: int) -> None: + session.permanent = True + session[SESSION_KEY] = { + "subject": p.subject, + "name": p.name, + "roles": list(p.roles), + "method": p.method, + "exp": time.time() + seconds, + } + + # -- oppslag ---------------------------------------------------------- + def resolve(self) -> tuple[Principal, str]: + """Returnerer (principal, feilmelding). Kaster ikke – /api/config skal + kunne spørre «hvem er jeg?» uten å få 401 i fleisen.""" + p = self.from_session() + if p is not None: + return p, "" + for provider in self.providers: + try: + p = provider.authenticate(request) + except AuthError as e: + return self.anonymous(), str(e) + if p is not None: + return p, "" + return self.anonymous(), "" + + def describe(self) -> dict[str, Any]: + return { + "enabled": self.enabled, + "methods": [p.describe() for p in self.providers], + "logout_url": "/auth/logout" if self.enabled else None, + } + + +_auth: Authenticator | None = None + + +def current() -> Principal: + """Principalen for denne forespørselen, slått opp én gang.""" + if not hasattr(g, "principal"): + assert _auth is not None, "auth.install() er ikke kalt" + g.principal, g.auth_error = _auth.resolve() + return g.principal + + +def error() -> str: + current() + return getattr(g, "auth_error", "") + + +def require(action: str) -> Principal: + """Enten har denne brukeren lov, eller så stopper forespørselen her. + + 401 når vi ikke vet hvem det er (klienten skal logge inn), 403 når vi vet + hvem det er men rollen mangler handlingen. + """ + p = current() + if p.can(action): + return p + if p.anonymous: + abort(401, error() or _login_hint()) + abort(403, f"Rollen din ({', '.join(p.roles) or 'ingen'}) har ikke lov til dette.") + + +def _login_hint() -> str: + assert _auth is not None + names = {p.name for p in _auth.providers} + if names == {"pin"}: + return "Feil eller manglende PIN." + if "oidc" in names: + return "Du må logge inn." + return "Innlogging kreves." + + +# -------------------------------------------------------------------------- +# Oppsett og ruter +# -------------------------------------------------------------------------- + + +def secret_key(cfg: Config) -> bytes: + """Fast nøkkel, ellers logges alle ut ved hver restart av tjenesten.""" + configured = str(cfg.get("server", "secret_key", "")).strip() + if configured: + return configured.encode() + path = cfg.path("state") / "secret_key" + if path.is_file(): + return path.read_bytes() + path.parent.mkdir(parents=True, exist_ok=True) + key = secrets.token_bytes(32) + path.write_bytes(key) + path.chmod(0o600) + log.info("lagde ny sesjonsnøkkel i %s", path) + return key + + +bp = Blueprint("auth", __name__) + + +@bp.get("/auth/login") +def login(): + oidc = _auth.provider("oidc") if _auth else None + if not isinstance(oidc, OidcProvider): + abort(404, "OIDC er ikke satt opp") + try: + return redirect(oidc.authorize_url()) + except AuthError as e: + abort(502, str(e)) + + +@bp.get("/auth/callback") +def callback(): + oidc = _auth.provider("oidc") if _auth else None + if not isinstance(oidc, OidcProvider): + abort(404, "OIDC er ikke satt opp") + if err := request.args.get("error"): + abort(401, f"{err}: {request.args.get('error_description', '')}".strip(": ")) + state = session.pop("oidc_state", None) + verifier = session.pop("oidc_verifier", None) + if not state or not verifier or request.args.get("state") != state: + abort(400, "Innloggingen kom ut av takt. Prøv på nytt.") + code = request.args.get("code", "") + if not code: + abort(400, "Ingen kode fra identitetsleverandøren") + try: + tokens = oidc.exchange(code, verifier) + id_token = tokens.get("id_token") + if not id_token: + abort(502, "Fikk ikke id_token fra identitetsleverandøren") + claims = oidc.verifier().verify(id_token) + except AuthError as e: + abort(401, str(e)) + p = oidc.principal(claims) + ttl = int(claims.get("exp", 0) - time.time()) if claims.get("exp") else 0 + _auth.remember(p, max(ttl, 3600)) + log.info("innlogget %s (%s) via oidc", p.name, p.subject) + return redirect("/") + + +@bp.get("/auth/token") +def token_login(): + """Bro mens crew-panelet mangler OIDC: panelet lenker hit med et kortlevd + JWT, vi verifiserer det og setter sesjonscookien. Ett klikk, ingen PIN.""" + provider = _auth.provider("jwt") if _auth else None + if not isinstance(provider, JwtProvider): + abort(404, "JWT er ikke satt opp") + raw = request.args.get("t") or request.args.get("token") or "" + if not raw.strip(): + abort(400, "Mangler token") + try: + claims = provider.verifier.verify(raw.strip()) + except AuthError as e: + abort(401, str(e)) + p = provider.principal(claims) + ttl = int(claims.get("exp", 0) - time.time()) if claims.get("exp") else 0 + _auth.remember(p, max(ttl, provider.session_seconds)) + log.info("innlogget %s (%s) via token-lenke", p.name, p.subject) + return redirect("/") + + +@bp.get("/auth/logout") +def logout(): + session.clear() + oidc = _auth.provider("oidc") if _auth else None + if isinstance(oidc, OidcProvider) and oidc._meta: + if end := oidc._meta.get("end_session_endpoint"): + return redirect(end) + return redirect("/") + + +def install(app, cfg: Config) -> Authenticator: # noqa: ANN001 + """Kobler autentisering på Flask-appen. Returnerer Authenticatoren.""" + global _auth + _auth = Authenticator(cfg) + app.secret_key = secret_key(cfg) + app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + # Secure bare bak TLS: på rå HTTP over LAN/NetBird ville cookien + # aldri blitt sendt, og ingen ville klart å logge inn. + SESSION_COOKIE_SECURE=bool(cfg.get("server", "behind_tls", False)), + ) + app.register_blueprint(bp) + return _auth diff --git a/docs/crew-integration.md b/docs/crew-integration.md new file mode 100644 index 0000000..752a3b0 --- /dev/null +++ b/docs/crew-integration.md @@ -0,0 +1,204 @@ +# Koble announcer til crew-panelet + +Announcer har ingen brukerdatabase og skal ikke ha det. Den stoler på +crew-panelet og leser identitet og roller ut av det panelet sier. Dette +dokumentet er kontrakten: hva panelet må levere, og i hvilken rekkefølge det er +verdt å bygge det. + +Panelet har i dag verken JWT eller OIDC. **Bygg JWT først** – det er noen titalls +linjer i panelet og dekker behovet. OIDC kan komme etterpå uten at noe i +announcer må skrives om; begge veier ender i samme kode. + +--- + +## Trinn 1: JWT (anbefalt start) + +Panelet signerer et kortlevd token som sier hvem brukeren er og hvilke roller +han har. Announcer verifiserer signaturen og stoler på innholdet. + +### Hva tokenet skal inneholde + +```json +{ + "iss": "https://crew.example.no", + "aud": "announcer", + "sub": "142", + "name": "Kari Nordmann", + "roles": ["crew"], + "exp": 1760000000 +} +``` + +| Claim | Krav | Merknad | +|---|---|---| +| `iss` | må stemme med `auth.jwt.issuer` | sjekkes bare hvis den er satt i config | +| `aud` | må stemme med `auth.jwt.audience` | bruk `"announcer"` | +| `sub` | påkrevd i praksis | stabil bruker-id; havner i loggen som `jwt:` | +| `exp` | **påkrevd** | announcer avviser tokens uten utløpstid | +| `name` | valgfri | vises i køen. Faller tilbake på `preferred_username`, `email`, så `sub` | +| `roles` | valgfri | se under | + +`roles` kan være en liste (`["crew"]`), en streng (`"crew"`), mellomroms- eller +kommadelt (`"crew admin"`), og den kan ligge nestet – da peker du på den med +`role_claim = "realm_access.roles"`. Mangler den helt, får brukeren +`auth.jwt.default_role`, eller ingen rettigheter hvis den ikke er satt. + +Rollenavnene betyr ingenting for announcer i seg selv; `[roles]` i `config.toml` +bestemmer hva de får lov til. + +### Signering + +To varianter. Begge virker, den andre er bedre: + +**HS256 med delt hemmelighet** – enklest. Samme streng i panelet og i +announcer. Ulempen er at announcer dermed har en nøkkel som *kan lage* gyldige +tokens. + +```toml +[auth.jwt] +issuer = "https://crew.example.no" +audience = "announcer" +# settes som ANNOUNCER_JWT_SECRET i systemd-enheten, ikke her +``` + +**RS256 med JWKS** – panelet signerer med sin private nøkkel og publiserer den +offentlige på `/.well-known/jwks.json`. Announcer henter den og cacher den en +time. Da kan announcer bare verifisere, aldri signere, og nøkkelrullering +skjer av seg selv. + +```toml +[auth.jwt] +issuer = "https://crew.example.no" +audience = "announcer" +jwks_url = "https://crew.example.no/.well-known/jwks.json" +``` + +### Hvordan brukeren kommer inn + +To måter, og panelet kan bruke begge: + +**Lenke (det du vil ha for et crew-medlem).** Panelet lager en knapp «Åpne +announcer» som peker på + +``` +http://:8090/auth/token?t= +``` + +Announcer verifiserer tokenet, setter en sesjonscookie og sender brukeren til +forsida. Ett klikk, ingen PIN å huske. Lag tokenet kortlevd (`exp` et par +minutter fram) – det er bare en billett inn; sesjonen varer så lenge tokenets +`exp` sier, eller `session_seconds` hvis det er lengre. + +**Header (for skript og integrasjoner).** Send tokenet på hver forespørsel: + +```bash +curl -X POST http://announcer:8090/api/announce \ + -H "Authorization: Bearer $JWT" \ + -H 'Content-Type: application/json' \ + -d '{"text":"Testmelding","voice":"finn"}' +``` + +### Minimum i panelet (Python-eksempel) + +```python +import time, jwt # pyjwt + +def announcer_url(user) -> str: + token = jwt.encode( + { + "iss": "https://crew.example.no", + "aud": "announcer", + "sub": str(user.id), + "name": user.full_name, + "roles": ["admin" if user.is_crew_lead else "crew"], + "exp": time.time() + 120, + }, + ANNOUNCER_JWT_SECRET, + algorithm="HS256", + ) + return f"http://announcer.lan:8090/auth/token?t={token}" +``` + +--- + +## Trinn 2: OIDC + +Når panelet har fått `authorize`-, `token`- og gjerne `userinfo`-endepunkter, +kan announcer bli en vanlig OIDC-klient. Da logger brukeren inn i panelet i +nettleseren og trenger ingen lenke med token i. + +Announcer bruker autorisasjonskode med PKCE (S256). Dette må registreres i +panelet: + +| | | +|---|---| +| `client_id` | f.eks. `announcer` | +| `redirect_uri` | `http://:8090/auth/callback` – **eksakt**, inkludert port | +| `grant_type` | `authorization_code` | +| `scope` | `openid profile email` (eller bare `openid`) | +| PKCE | `S256` | + +Announcer leser `{issuer}/.well-known/openid-configuration` ved første +innlogging. Har ikke panelet discovery, settes endepunktene direkte: + +```toml +[auth.oidc] +issuer = "https://crew.example.no" +client_id = "announcer" +authorization_endpoint = "https://crew.example.no/oauth/authorize" +token_endpoint = "https://crew.example.no/oauth/token" +jwks_url = "https://crew.example.no/.well-known/jwks.json" +``` + +`client_secret` settes som `ANNOUNCER_OIDC_CLIENT_SECRET`. Rollene leses ut av +`id_token` på samme måte som over. + +Står announcer bak en omvendt proxy, sett `server.public_url` så +`redirect_uri` blir riktig, og `server.behind_tls = true`. + +--- + +## Roller og rettigheter + +Fem handlinger. `config.toml` kobler rollenavn til dem: + +| Handling | Hva den gir | +|---|---| +| `view` | se køen og hva som spilles nå | +| `preview` | forhåndslytte (koster en syntese) | +| `announce` | sende ut på anlegget | +| `stop` | stoppe det som spilles | +| `clear` | tømme køen (og se IP-adresser i køen) | + +```toml +[roles] +anonymous = [] # ["view"] for en statusskjerm +crew = ["view", "preview", "announce"] +admin = ["*"] +``` + +Endrer du `[roles]` slår det inn ved neste forespørsel – ingen må logge inn på +nytt. (Nye `[auth.*]`-verdier krever omstart.) + +## Hva announcer svarer + +| Kode | Betyr | Hva klienten skal gjøre | +|---|---|---| +| 401 | vi vet ikke hvem du er | logg inn | +| 403 | vi vet hvem du er, rollen din får ikke dette | ikke prøv igjen | + +`GET /api/config` svarer alltid 200 og forteller i `auth`-blokka hvilke metoder +som er aktive, hvem du er og hva du får gjøre. Det er den sida bruker for å +vise eller skjule knapper. + +## Loggen + +Hvert utrop havner i `logs/announcements.jsonl`, én JSON-linje per hendelse: + +```json +{"ts":1760000000.12,"action":"announce","subject":"jwt:142","name":"Kari Nordmann", + "method":"jwt","ip":"10.20.70.55","id":7,"text":"Pizzaen er kommet","voice":"finn"} +``` + +`action` er `announce`, `played`, `stop` eller `clear`. Fila rullerer til +`.jsonl.1` ved 5 MB. diff --git a/index.html b/index.html index c6ca57e..b2dcbb8 100644 --- a/index.html +++ b/index.html @@ -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; }
-

Announcer

-

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

+
+
+

+

+
+
+
+ +
+
+
- - + + +
- +
0 / 600
@@ -122,68 +138,165 @@ const $ = s => document.querySelector(s); const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime'); const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce'); -let cfg = {}, previewedKey = null; - -const QUICK = [ - 'Hei alle sammen! Pizzaen er kommet, hent i kantina.', - 'Turneringen starter om ti minutter. Møt opp ved scenen.', - 'Husk å rydde rundt plassen din. Takk!', - 'Natta! Vi senker lyset, hold volumet nede.', -]; +let cfg = {}, can = [], previewedKey = null, pollTimer = null; function key() { return JSON.stringify(payload()); } function payload() { return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.value }; } -function headers() { - const h = { 'Content-Type': 'application/json' }; +function headers(json) { + const h = json ? { 'Content-Type': 'application/json' } : {}; + // PIN-en er den eneste legitimasjonen som ligger i nettleseren; OIDC og + // token-lenka bruker sesjonscookien, som følger med av seg selv. const pin = localStorage.getItem('pin'); if (pin) h['X-Pin'] = pin; return h; } function say(t, cls) { msg.textContent = t; msg.className = 'msg ' + (cls || ''); } +function may(action) { return can.includes(action); } function updateAnnounce() { - btnAnnounce.disabled = !(previewedKey && previewedKey === key()); - btnAnnounce.title = btnAnnounce.disabled ? 'Forhåndslytt først' : ''; + const ready = previewedKey && previewedKey === key(); + btnAnnounce.disabled = !ready || !may('announce'); + btnAnnounce.title = !may('announce') ? 'Du har ikke lov til å sende ut' : + (btnAnnounce.disabled ? 'Forhåndslytt først' : ''); + btnPreview.disabled = !may('preview'); + btnPreview.title = may('preview') ? '' : 'Du har ikke lov til å forhåndslytte'; } -async function api(path, body) { - const r = await fetch(path, { method: 'POST', headers: headers(), body: JSON.stringify(body || {}) }); + +async function req(path, opts) { + const r = await fetch(path, opts); if (!r.ok) { - let e = r.statusText; try { e = (await r.json()).error || e; } catch {} - throw new Error(e); + let e = r.statusText, login = false; + try { const j = await r.json(); e = j.error || e; login = !!j.login; } catch {} + const err = new Error(e); + err.status = r.status; err.login = login; + throw err; } return r; } +const api = (path, body) => + req(path, { method: 'POST', headers: headers(true), body: JSON.stringify(body || {}) }); +function onError(e) { + say(e.message, 'err'); + // Rettighetene kan ha endret seg (riktig PIN skrevet, sesjon utløpt). + if (e.status === 401 || e.status === 403) refreshAuth(); +} + +// ---------------------------------------------------------------- branding +function applyConfig(c) { + cfg = c; + const a = c.app || {}; + document.title = a.title || 'Announcer'; + // Siste ord i tittelen får aksentfargen; ett ord alene står som det er. + const parts = (a.title || 'Announcer').split(' '); + $('#title').textContent = ''; + if (parts.length > 1) { + $('#title').append(document.createTextNode(parts.slice(0, -1).join(' ') + ' ')); + const sp = document.createElement('span'); sp.textContent = parts.at(-1); $('#title').append(sp); + } else { + $('#title').textContent = parts[0]; + } + $('#subtitle').textContent = a.subtitle || ''; + if (a.accent) document.documentElement.style.setProperty('--accent', a.accent); + text.placeholder = a.message_placeholder || ''; + $('#max').textContent = c.max_chars; text.maxLength = c.max_chars; + + $('#chips').replaceChildren(...(a.quick_phrases || []).map(q => { + const b = document.createElement('button'); + b.className = 'chip'; b.textContent = q.slice(0, 32) + (q.length > 32 ? '…' : ''); b.title = q; + b.onclick = () => { text.value = q; text.dispatchEvent(new Event('input')); text.focus(); }; + return b; + })); +} + +// -------------------------------------------------------------------- auth +function applyAuth(auth) { + can = auth.can || []; + const methods = auth.methods || []; + const who = $('#who'); + + if (auth.user) { + who.textContent = ''; + who.append(document.createTextNode('Innlogget som ')); + const b = document.createElement('b'); b.textContent = auth.user.name; who.append(b); + if (auth.user.roles.length) who.append(document.createTextNode(' (' + auth.user.roles.join(', ') + ')')); + if (auth.logout_url) { + who.append(document.createTextNode(' · ')); + const a = document.createElement('a'); a.href = auth.logout_url; a.textContent = 'Logg ut'; who.append(a); + } + } else if (auth.enabled) { + who.textContent = 'Ikke innlogget'; + } else { + who.textContent = ''; + } + + // OIDC-knapper + const oidc = methods.filter(m => m.login_url); + $('#loginbox').style.display = (oidc.length && !auth.user) ? 'block' : 'none'; + $('#loginbtns').replaceChildren(...oidc.map(m => { + const a = document.createElement('a'); + a.className = 'btn primary'; a.href = m.login_url; a.textContent = m.label || 'Logg inn'; + return a; + })); + + // PIN-feltet, bare når PIN er en av metodene og vi ikke alt er innlogget + const pin = methods.find(m => m.method === 'pin'); + $('#pinbox').style.display = (pin && !auth.user) ? 'block' : 'none'; + if (pin) { + $('#pinlabel').textContent = pin.label || 'PIN'; + $('#pinhint').textContent = auth.error || pin.hint || ''; + $('#pinhint').style.color = auth.error ? 'var(--danger)' : 'var(--muted)'; + if (!$('#pin').value) $('#pin').value = localStorage.getItem('pin') || ''; + } + + $('#btnStop').classList.toggle('hidden', !may('stop')); + $('#btnClear').classList.toggle('hidden', !may('clear')); + updateAnnounce(); +} + +async function refreshAuth() { + try { + const c = await (await fetch('/api/config', { headers: headers(false) })).json(); + applyAuth(c.auth || {}); + } catch {} +} + +// -------------------------------------------------------------------- init async function init() { - cfg = await (await fetch('/api/config')).json(); + const c = await (await fetch('/api/config', { headers: headers(false) })).json(); + applyConfig(c); + const groups = {}; - for (const v of cfg.voices) { + for (const v of c.voices) { if (!groups[v.group]) { groups[v.group] = document.createElement('optgroup'); groups[v.group].label = v.group; voice.append(groups[v.group]); } const o = document.createElement('option'); o.value = v.id; o.textContent = v.name; groups[v.group].append(o); } - voice.value = localStorage.getItem('voice') || cfg.default_voice; - if (!voice.value) voice.value = cfg.default_voice; - for (const c of cfg.chimes) { - const o = document.createElement('option'); o.value = c.id; o.textContent = c.name; chime.append(o); + voice.value = localStorage.getItem('voice') || c.default_voice; + if (!voice.value) voice.value = c.default_voice; + for (const ch of c.chimes) { + const o = document.createElement('option'); o.value = ch.id; o.textContent = ch.name; chime.append(o); } - chime.value = localStorage.getItem('chime') || cfg.default_chime; - if (!chime.value) chime.value = cfg.default_chime; - $('#max').textContent = cfg.max_chars; text.maxLength = cfg.max_chars; - if (cfg.pin_required) { $('#pinbox').style.display = 'block'; $('#pin').value = localStorage.getItem('pin') || ''; } - for (const q of QUICK) { - const b = document.createElement('button'); b.className = 'chip'; b.textContent = q.slice(0, 32) + (q.length > 32 ? '…' : ''); - b.title = q; b.onclick = () => { text.value = q; text.dispatchEvent(new Event('input')); text.focus(); }; - $('#chips').append(b); - } - poll(); setInterval(poll, 2000); + chime.value = localStorage.getItem('chime') || c.default_chime; + if (!chime.value) chime.value = c.default_chime; + + applyAuth(c.auth || {}); + poll(); + pollTimer = setInterval(poll, 2000); } +let pinDebounce = null; +$('#pin').addEventListener('input', e => { + localStorage.setItem('pin', e.target.value); + clearTimeout(pinDebounce); + // Vent til han er ferdig å skrive, så slipper vi en runde per tastetrykk. + pinDebounce = setTimeout(() => { refreshAuth(); poll(); }, 600); +}); + text.addEventListener('input', () => { $('#count').textContent = text.value.length; updateAnnounce(); }); voice.addEventListener('change', () => { localStorage.setItem('voice', voice.value); updateAnnounce(); }); speed.addEventListener('input', () => { $('#speedval').textContent = parseFloat(speed.value).toFixed(2).replace(/0$/, '') + '×'; updateAnnounce(); }); chime.addEventListener('change', () => { localStorage.setItem('chime', chime.value); updateAnnounce(); }); -$('#pin').addEventListener('input', e => localStorage.setItem('pin', e.target.value)); btnPreview.addEventListener('click', async () => { const p = payload(); @@ -195,10 +308,10 @@ btnPreview.addEventListener('click', async () => { if (audio.src) URL.revokeObjectURL(audio.src); audio.src = URL.createObjectURL(blob); audio.classList.add('show'); await audio.play().catch(() => {}); - previewedKey = JSON.stringify(p); updateAnnounce(); + previewedKey = JSON.stringify(p); say('Hør gjennom. Fornøyd? Send den ut.', 'ok'); - } catch (e) { say(e.message, 'err'); } - finally { btnPreview.disabled = false; } + } catch (e) { onError(e); } + finally { btnPreview.disabled = false; updateAnnounce(); } }); btnAnnounce.addEventListener('click', async () => { @@ -209,29 +322,40 @@ btnAnnounce.addEventListener('click', async () => { text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null; audio.pause(); audio.classList.remove('show'); poll(); - } catch (e) { say(e.message, 'err'); updateAnnounce(); } + } catch (e) { onError(e); } + finally { updateAnnounce(); } }); -$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(e => say(e.message, 'err'))); -$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(e => say(e.message, 'err'))); +$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(onError)); +$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(onError)); const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø' }; function hhmm(ts) { return new Date(ts * 1000).toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' }); } function li(it) { const l = document.createElement('li'); l.className = it.status; - l.innerHTML = `${hhmm(it.created)}${STATUS[it.status] || it.status}${it.error ? ' · ' + it.error : ''}`; + l.innerHTML = ``; + l.querySelector('.t').textContent = hhmm(it.created); l.querySelector('.x').textContent = it.text; + // Navnet står her, ikke IP-en. IP-en kommer bare med for en admin. + l.querySelector('.by').textContent = [it.user, it.sender].filter(Boolean).join(' · '); + l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : ''); return l; } async function poll() { + const now = $('#now'); try { - const s = await (await fetch('/api/status')).json(); - const now = $('#now'); + const s = await (await req('/api/status', { headers: headers(false) })).json(); if (s.current) { now.className = 'now'; now.innerHTML = ''; now.append(document.createTextNode(s.current.text)); } - else { now.className = 'now idle'; now.textContent = ''; } + else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; } $('#queue').replaceChildren(...s.queued.map(li)); $('#history').replaceChildren(...s.history.map(li)); - } catch {} + } catch (e) { + if (e.status === 401 || e.status === 403) { + now.className = 'now idle'; + now.textContent = 'Logg inn for å se køen.'; + $('#queue').replaceChildren(); $('#history').replaceChildren(); + } + } } init(); diff --git a/install.sh b/install.sh index 41a7d0e..fc16cc3 100755 --- a/install.sh +++ b/install.sh @@ -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 <}:$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). diff --git a/pyproject.toml b/pyproject.toml index f5ddabf..9f65df8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f015824 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +"""Felles oppsett for testene. + +Hele poenget her er å nulle ut miljøet. `config.load()` lar +ANNOUNCER_*-variabler overstyre konfigurasjonen, og det er riktig oppførsel – +men det betyr at testene ellers arver det maskina tilfeldigvis har satt. +På announcer-maskina setter systemd-enheten `ANNOUNCER_PORT`, og i +docker compose settes både JWT- og OIDC-hemmeligheter. Uten denne fila feiler +28 av testene der, med feilmeldinger som ikke peker på noe som er galt. +""" + +from __future__ import annotations + +import os + +import pytest + + +@pytest.fixture(autouse=True) +def rent_miljø(monkeypatch): + """Fjerner alle ANNOUNCER_*-variabler, så hver test bare ser sin egen config.""" + for key in [k for k in os.environ if k.startswith("ANNOUNCER_")]: + monkeypatch.delenv(key, raising=False) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..199974a --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,535 @@ +"""Tester for autentiseringen. + +Auth er den ene delen av denne appen man ikke kan se etter med øyet: at en PIN +virker ser du med en gang, at et utløpt token blir avvist ser du aldri før det +smeller. Derfor står prøvene her og ikke rundt resten. + +Testene kjører mot en bar Flask-app, ikke mot announcer.py – det ville dratt inn +piper, lastet stemmer og startet avspillingstråden. +""" + +from __future__ import annotations + +import sys +import time +import urllib.parse +from pathlib import Path + +import jwt as pyjwt +import pytest +from flask import Flask, jsonify + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import auth # noqa: E402 +import config # noqa: E402 + +SECRET = "en-delt-hemmelighet-mellom-panelet-og-announcer" + + +def build(tmp_path: Path, toml: str = "", **env: str): + """Lager en liten app med gitt konfigurasjon. Returnerer testklienten.""" + cfg_path = tmp_path / "config.toml" + cfg_path.write_text(f'[paths]\nstate = "{tmp_path}"\nlog = "{tmp_path}/a.jsonl"\n\n{toml}') + for key, value in env.items(): + __import__("os").environ[key] = value + try: + cfg = config.load(cfg_path) + finally: + for key in env: + __import__("os").environ.pop(key, None) + + app = Flask(__name__) + app.config["TESTING"] = True + authenticator = auth.install(app, cfg) + + @app.get("/do/") + def do(action: str): # noqa: ANN202 + return jsonify(auth.require(action).public()) + + @app.errorhandler(401) + def _401(e): # noqa: ANN001, ANN202 + return jsonify({"error": e.description}), 401 + + @app.errorhandler(403) + def _403(e): # noqa: ANN001, ANN202 + return jsonify({"error": e.description}), 403 + + return app.test_client(), authenticator + + +def token(claims: dict, *, secret: str = SECRET, alg: str = "HS256", **over) -> str: + body = {"sub": "u1", "exp": time.time() + 600, **claims, **over} + return pyjwt.encode(body, secret, algorithm=alg) + + +# -------------------------------------------------------------------------- +# Uten konfigurasjon skal ingenting være stengt +# -------------------------------------------------------------------------- + + +def test_uten_konfig_er_alt_apent(tmp_path): + c, a = build(tmp_path) + assert a.enabled is False + for action in config.ACTIONS: + assert c.get(f"/do/{action}").status_code == 200, action + + +def test_tom_providers_liste_er_fortsatt_apen(tmp_path): + c, a = build(tmp_path, "[auth]\nproviders = []\n") + assert a.enabled is False + assert c.get("/do/clear").status_code == 200 + + +# -------------------------------------------------------------------------- +# PIN +# -------------------------------------------------------------------------- + + +def test_pin_kreves_nar_den_er_satt(tmp_path): + c, a = build(tmp_path, '[auth.pin]\npin = "1234"\n') + assert a.enabled is True + assert [p.name for p in a.providers] == ["pin"] + assert c.get("/do/announce").status_code == 401 + assert c.get("/do/announce", headers={"X-Pin": "feil"}).status_code == 401 + assert c.get("/do/announce", headers={"X-Pin": "1234"}).status_code == 200 + + +def test_pin_fra_miljovariabel_slar_pa_leverandoren(tmp_path): + """Bakoverkompatibilitet: dette er alt systemd-enheten har satt fram til nå.""" + c, a = build(tmp_path, ANNOUNCER_PIN="9999") + assert [p.name for p in a.providers] == ["pin"] + assert c.get("/do/clear", headers={"X-Pin": "9999"}).status_code == 200 + assert c.get("/do/clear").status_code == 401 + + +def test_feil_pin_gir_forstaelig_melding(tmp_path): + c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n') + assert c.get("/do/announce", headers={"X-Pin": "nope"}).get_json()["error"] == "Feil PIN" + + +def test_pin_gir_admin_som_standard(tmp_path): + c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n') + body = c.get("/do/clear", headers={"X-Pin": "1234"}).get_json() + assert body["roles"] == ["admin"] + assert sorted(body["can"]) == sorted(config.ACTIONS) + + +def test_pin_med_crew_rolle_far_ikke_tomme_koen(tmp_path): + c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\nrole = "crew"\n') + assert c.get("/do/announce", headers={"X-Pin": "1234"}).status_code == 200 + r = c.get("/do/clear", headers={"X-Pin": "1234"}) + assert r.status_code == 403 + assert "crew" in r.get_json()["error"] + + +def test_pin_godtas_ogsa_i_authorization_header(tmp_path): + c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n') + assert c.get("/do/announce", headers={"Authorization": "Pin 1234"}).status_code == 200 + + +# -------------------------------------------------------------------------- +# Anonym rolle +# -------------------------------------------------------------------------- + + +def test_anonym_kan_gis_leserett(tmp_path): + """For statusskjermen som henger på veggen uten noen til å logge inn.""" + c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n\n[roles]\nanonymous = ["view"]\n') + assert c.get("/do/view").status_code == 200 + assert c.get("/do/announce").status_code == 401 + + +# -------------------------------------------------------------------------- +# JWT +# -------------------------------------------------------------------------- + + +JWT_CFG = f''' +[auth.jwt] +secret = "{SECRET}" +audience = "announcer" +issuer = "https://crew.example.no" +''' + + +def test_gyldig_jwt_slipper_inn_med_rolle_og_navn(tmp_path): + c, a = build(tmp_path, JWT_CFG) + assert [p.name for p in a.providers] == ["jwt"] + t = token({"aud": "announcer", "iss": "https://crew.example.no", + "name": "Kari Nordmann", "roles": ["crew"], "sub": "42"}) + body = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).get_json() + assert body["name"] == "Kari Nordmann" + assert body["roles"] == ["crew"] + assert body["subject"] == "jwt:42" + assert body["method"] == "jwt" + + +def test_utlopt_jwt_avvises(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]}, + exp=time.time() - 3600) + r = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}) + assert r.status_code == 401 + assert "utløpt" in r.get_json()["error"] + + +def test_jwt_for_en_annen_tjeneste_avvises(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "et-annet-system", "iss": "https://crew.example.no", "roles": ["admin"]}) + r = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}) + assert r.status_code == 401 + assert "annen tjeneste" in r.get_json()["error"] + + +def test_jwt_fra_feil_utsteder_avvises(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://ondsinnet.example", "roles": ["admin"]}) + assert c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).status_code == 401 + + +def test_jwt_med_feil_signatur_avvises(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]}, + secret="en-helt-annen-hemmelighet-som-er-lang-nok-for-hmac") + assert c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).status_code == 401 + + +def test_jwt_uten_exp_avvises(tmp_path): + """Et token uten utløpstid er en evig nøkkel; den vil vi ikke ha.""" + c, _ = build(tmp_path, JWT_CFG) + body = {"sub": "u1", "aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]} + t = pyjwt.encode(body, SECRET, algorithm="HS256") + assert c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).status_code == 401 + + +def test_jwt_rolle_uten_rettighet_gir_403_ikke_401(tmp_path): + """Forskjellen betyr noe for klienten: 401 = logg inn, 403 = du får ikke.""" + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["crew"]}) + h = {"Authorization": f"Bearer {t}"} + assert c.get("/do/announce", headers=h).status_code == 200 + assert c.get("/do/clear", headers=h).status_code == 403 + + +def test_ingen_authorization_header_gir_401(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + assert c.get("/do/view").status_code == 401 + + +@pytest.mark.parametrize( + "claims, role_claim, forventet", + [ + ({"roles": ["crew", "admin"]}, "roles", ["crew", "admin"]), + ({"roles": "crew admin"}, "roles", ["crew", "admin"]), + ({"roles": "crew,admin"}, "roles", ["crew", "admin"]), + ({"roles": "crew"}, "roles", ["crew"]), + ({"realm_access": {"roles": ["crew"]}}, "realm_access.roles", ["crew"]), + ({"groups": ["admin"]}, "groups", ["admin"]), + ({}, "roles", []), + ], +) +def test_rolleclaim_tolkes_i_alle_formene(tmp_path, claims, role_claim, forventet): + c, _ = build(tmp_path, JWT_CFG + f'role_claim = "{role_claim}"\n') + t = token({"aud": "announcer", "iss": "https://crew.example.no", **claims}) + r = c.get("/do/view", headers={"Authorization": f"Bearer {t}"}) + if forventet: + assert r.get_json()["roles"] == forventet + else: + # Gyldig token uten roller: vi *vet* hvem dette er, han får bare ikke + # gjøre noe. Da er 403 riktig – å logge inn på nytt hjelper ikke. + assert r.status_code == 403 + + +def test_default_role_brukes_nar_tokenet_mangler_roller(tmp_path): + c, _ = build(tmp_path, JWT_CFG + 'default_role = "crew"\n') + t = token({"aud": "announcer", "iss": "https://crew.example.no"}) + body = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).get_json() + assert body["roles"] == ["crew"] + + +@pytest.mark.parametrize( + "claims, forventet", + [ + ({"name": "Kari"}, "Kari"), + ({"preferred_username": "kari"}, "kari"), + ({"email": "kari@example.no"}, "kari@example.no"), + ({}, "u1"), + ], +) +def test_visningsnavn_finnes_fram_fra_det_som_er(tmp_path, claims, forventet): + c, _ = build(tmp_path, JWT_CFG + 'default_role = "admin"\n') + t = token({"aud": "announcer", "iss": "https://crew.example.no", **claims}) + assert c.get("/do/view", headers={"Authorization": f"Bearer {t}"}).get_json()["name"] == forventet + + +# -------------------------------------------------------------------------- +# Token-lenka fra crew-panelet +# -------------------------------------------------------------------------- + + +def test_token_lenke_gir_sesjon_sa_resten_virker_uten_header(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://crew.example.no", + "name": "Kent", "roles": ["admin"]}) + r = c.get(f"/auth/token?t={t}") + assert r.status_code == 302 + body = c.get("/do/clear").get_json() # ingen header denne gangen + assert body["name"] == "Kent" + assert body["method"] == "jwt" + + +def test_token_lenke_med_ugyldig_token_gir_ingen_sesjon(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + assert c.get("/auth/token?t=aldeles-ugyldig").status_code == 401 + assert c.get("/do/view").status_code == 401 + + +def test_token_lenke_finnes_ikke_uten_jwt_oppsett(tmp_path): + c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n') + assert c.get("/auth/token?t=hva-som-helst").status_code == 404 + + +def test_utlogging_tommer_sesjonen(tmp_path): + c, _ = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]}) + c.get(f"/auth/token?t={t}") + assert c.get("/do/clear").status_code == 200 + c.get("/auth/logout") + assert c.get("/do/clear").status_code == 401 + + +def test_rettigheter_slas_opp_pa_nytt_for_hver_forespørsel(tmp_path): + """Endrer man [roles] skal det slå inn uten at folk må logge inn på nytt.""" + c, a = build(tmp_path, JWT_CFG) + t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["crew"]}) + c.get(f"/auth/token?t={t}") + assert c.get("/do/clear").status_code == 403 + a.cfg.data["roles"]["crew"] = ["*"] + assert c.get("/do/clear").status_code == 200 + + +# -------------------------------------------------------------------------- +# Flere metoder samtidig +# -------------------------------------------------------------------------- + + +def test_pin_og_jwt_kan_virke_side_om_side(tmp_path): + c, a = build(tmp_path, JWT_CFG + '\n[auth.pin]\npin = "1234"\nrole = "crew"\n') + assert sorted(p.name for p in a.providers) == ["jwt", "pin"] + assert c.get("/do/announce", headers={"X-Pin": "1234"}).status_code == 200 + t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]}) + assert c.get("/do/clear", headers={"Authorization": f"Bearer {t}"}).status_code == 200 + + +def test_rekkefolgen_i_providers_bestemmer(tmp_path): + c, a = build(tmp_path, '[auth]\nproviders = ["jwt", "pin"]\n' + JWT_CFG + '\n[auth.pin]\npin = "1"\n') + assert [p.name for p in a.providers] == ["jwt", "pin"] + + +# -------------------------------------------------------------------------- +# Enhetstester på småtingene +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value, forventet", + [ + (["a", "b"], ("a", "b")), + ("a b", ("a", "b")), + ("a,b", ("a", "b")), + ("a", ("a",)), + (None, ()), + (42, ()), + ([" a ", ""], ("a",)), + ], +) +def test_roller_tolkes(value, forventet): + assert auth._roles(value) == forventet + + +def test_nestet_claim_hentes(): + claims = {"realm_access": {"roles": ["crew"]}} + assert auth._claim(claims, "realm_access.roles") == ["crew"] + assert auth._claim(claims, "finnes.ikke") is None + assert auth._claim(claims, "realm_access.roles.dypere") is None + + +def test_rolle_til_handling(): + cfg = config.load("/finnes-ikke.toml") + assert cfg.permissions(("admin",)) == frozenset(config.ACTIONS) + assert cfg.permissions(("crew",)) == frozenset({"view", "preview", "announce"}) + assert cfg.permissions(("ukjent-rolle",)) == frozenset() + assert cfg.permissions(()) == frozenset() + # Roller legges sammen + cfg.data["roles"]["bare_stopp"] = ["stop"] + assert cfg.permissions(("crew", "bare_stopp")) == frozenset({"view", "preview", "announce", "stop"}) + + +def test_ukjente_handlinger_i_roles_ignoreres(): + cfg = config.load("/finnes-ikke.toml") + cfg.data["roles"]["rar"] = ["announce", "slett-alt"] + assert cfg.permissions(("rar",)) == frozenset({"announce"}) + + +def test_sesjonsnokkel_er_stabil_mellom_omstarter(tmp_path): + cfg = config.load("/finnes-ikke.toml") + cfg.data["paths"]["state"] = str(tmp_path) + first = auth.secret_key(cfg) + assert auth.secret_key(cfg) == first + assert (tmp_path / "secret_key").stat().st_mode & 0o777 == 0o600 + + +# -------------------------------------------------------------------------- +# OIDC +# -------------------------------------------------------------------------- +# Crew-panelet har ikke OIDC-endepunkter ennå, så her står en stubbet +# identitetsleverandør i stedet. Det er HTTP-kallene som stubbes; discovery, +# PKCE, state-sjekken, kodebyttet og verifiseringen av id_token er vår egen +# kode og kjøres på ordentlig. + +OIDC_CFG = ''' +[auth.oidc] +issuer = "https://crew.example.no" +client_id = "announcer" +client_secret = "panel-hemmelighet-som-er-lang-nok-for-hmac-sha256" +''' + +DISCOVERY = { + "issuer": "https://crew.example.no", + "authorization_endpoint": "https://crew.example.no/oauth/authorize", + "token_endpoint": "https://crew.example.no/oauth/token", + "jwks_uri": "", +} + + +@pytest.fixture +def idp(monkeypatch): + """Stubber HTTP-laget og husker hva vi sendte til tokenendepunktet.""" + state: dict = {"posts": [], "claims": {}} + + def fake_get(url, timeout=10): + assert url == "https://crew.example.no/.well-known/openid-configuration" + return dict(DISCOVERY) + + def fake_post(url, form, headers=None, timeout=10): + state["posts"].append({"url": url, "form": form, "headers": headers or {}}) + claims = { + "iss": "https://crew.example.no", + "aud": "announcer", + "exp": time.time() + 600, + "sub": "142", + **state["claims"], + } + # Panelet signerer id_token med client_secret (HS256) i dette oppsettet. + return {"id_token": pyjwt.encode(claims, "panel-hemmelighet-som-er-lang-nok-for-hmac-sha256", algorithm="HS256")} + + monkeypatch.setattr(auth, "_get_json", fake_get) + monkeypatch.setattr(auth, "_post_json", fake_post) + return state + + +def test_oidc_login_sender_brukeren_til_panelet_med_pkce(tmp_path, idp): + c, _ = build(tmp_path, OIDC_CFG) + r = c.get("/auth/login") + assert r.status_code == 302 + url = urllib.parse.urlparse(r.headers["Location"]) + q = urllib.parse.parse_qs(url.query) + assert url.netloc == "crew.example.no" and url.path == "/oauth/authorize" + assert q["response_type"] == ["code"] + assert q["client_id"] == ["announcer"] + assert q["code_challenge_method"] == ["S256"] + assert q["redirect_uri"][0].endswith("/auth/callback") + assert len(q["code_challenge"][0]) == 43 # sha256, base64url uten padding + assert q["state"][0] + + +def test_oidc_hele_runden_gir_innlogget_sesjon(tmp_path, idp): + idp["claims"] = {"name": "Kari Nordmann", "roles": ["crew"]} + c, _ = build(tmp_path, OIDC_CFG) + q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query) + r = c.get(f"/auth/callback?code=en-kode&state={q['state'][0]}") + assert r.status_code == 302 and r.headers["Location"] == "/" + + body = c.get("/do/announce").get_json() + assert body["name"] == "Kari Nordmann" + assert body["roles"] == ["crew"] + assert body["subject"] == "oidc:142" + assert body["method"] == "oidc" + assert c.get("/do/clear").status_code == 403 + + # Kodebyttet: PKCE-verifier med, hemmeligheten som Basic-auth, ikke i body. + post = idp["posts"][0] + assert post["url"] == "https://crew.example.no/oauth/token" + assert post["form"]["grant_type"] == "authorization_code" + assert post["form"]["code"] == "en-kode" + assert len(post["form"]["code_verifier"]) > 40 + assert "client_secret" not in post["form"] + assert post["headers"]["Authorization"].startswith("Basic ") + + +def test_oidc_callback_med_feil_state_avvises(tmp_path, idp): + """Uten denne sjekken kan en angriper få deg til å logge inn som seg selv.""" + c, _ = build(tmp_path, OIDC_CFG) + c.get("/auth/login") + r = c.get("/auth/callback?code=en-kode&state=tilfeldig-tull") + assert r.status_code == 400 + assert c.get("/do/view").status_code == 401 + + +def test_oidc_callback_uten_forutgaende_login_avvises(tmp_path, idp): + c, _ = build(tmp_path, OIDC_CFG) + assert c.get("/auth/callback?code=x&state=y").status_code == 400 + + +def test_oidc_feil_fra_panelet_vises(tmp_path, idp): + c, _ = build(tmp_path, OIDC_CFG) + c.get("/auth/login") + r = c.get("/auth/callback?error=access_denied&error_description=Avvist+av+bruker") + assert r.status_code == 401 + assert "access_denied" in r.get_json()["error"] + + +def test_oidc_id_token_for_en_annen_klient_avvises(tmp_path, idp, monkeypatch): + c, _ = build(tmp_path, OIDC_CFG) + monkeypatch.setattr(auth, "_post_json", lambda url, form, headers=None, timeout=10: { + "id_token": pyjwt.encode( + {"iss": "https://crew.example.no", "aud": "et-annet-system", + "exp": time.time() + 600, "sub": "1"}, + "panel-hemmelighet-som-er-lang-nok-for-hmac-sha256", algorithm="HS256")}) + q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query) + assert c.get(f"/auth/callback?code=k&state={q['state'][0]}").status_code == 401 + + +def test_oidc_state_kan_ikke_brukes_to_ganger(tmp_path, idp): + c, _ = build(tmp_path, OIDC_CFG) + q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query) + state = q["state"][0] + assert c.get(f"/auth/callback?code=k&state={state}").status_code == 302 + c.get("/auth/logout") + assert c.get(f"/auth/callback?code=k&state={state}").status_code == 400 + + +def test_oidc_manuelle_endepunkter_hopper_over_discovery(tmp_path, monkeypatch): + """Panelet har ikke .well-known ennå, så dette er veien dit først.""" + def nei(*a, **kw): + raise AssertionError("skulle ikke slått opp discovery") + monkeypatch.setattr(auth, "_get_json", nei) + c, _ = build(tmp_path, OIDC_CFG + ''' +authorization_endpoint = "https://crew.example.no/oauth/authorize" +token_endpoint = "https://crew.example.no/oauth/token" +''') + assert c.get("/auth/login").status_code == 302 + + +def test_oidc_login_finnes_ikke_uten_oppsett(tmp_path): + c, _ = build(tmp_path, '[auth.pin]\npin = "1"\n') + assert c.get("/auth/login").status_code == 404 + assert c.get("/auth/callback?code=x&state=y").status_code == 404 + + +def test_public_url_styrer_redirect_uri(tmp_path, idp): + """Bak en omvendt proxy må redirect_uri peke utover, ikke på localhost.""" + c, _ = build(tmp_path, '[server]\npublic_url = "https://utrop.example.no"\n' + OIDC_CFG) + q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query) + assert q["redirect_uri"] == ["https://utrop.example.no/auth/callback"] diff --git a/uv.lock b/uv.lock index 5f91db7..2de02a3 100644 --- a/uv.lock +++ b/uv.lock @@ -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"