Compare commits

..

4 commits

Author SHA1 Message Date
6aaa98d8b1 Testoppsett i Docker med dummy-IdP
Crew-panelet har ikke OIDC ennå, så OIDC-veien kunne ikke prøves i det hele
tatt – bare enhetstestes mot et stubbet HTTP-lag. Dette gir noe å faktisk
logge inn i.

    docker compose up --build

    localhost:8091   announcer med PIN, JWT og OIDC slått på samtidig
    localhost:9000   docker/dummy_idp.py, som står for crew-panelet

8091 med vilje, så den ikke kolliderer med tjenesten på 8090.

Dummy-IdP-en kjører en ekte autorisasjonskode-runde: den krever PKCE med
S256, avviser feil code_verifier, bruker koden bare én gang, og signerer
id_token med klienthemmeligheten. Du velger hvem du logger inn som – Kari
(crew), Kent (admin) eller Ola, som har null roller og dermed skal gi 403
og ikke 401. /token?who=kari&go=1 logger deg inn via token-lenka, slik
panelet skal gjøre.

To ting i oppsettet som ser rart ut, men ikke er det:

- authorization_endpoint peker på localhost:9000 fordi nettleseren skal
  dit, mens token_endpoint peker på idp:9000 fordi announcer selv snakker
  med den inne i compose-nettet. Delt horisont. Det tester samtidig at
  manuelle endepunkter virker, som er veien når et panel mangler discovery.
- UV_PROJECT_ENVIRONMENT=/opt/venv i bildet: /app er et bind-mount av
  repoet, og uten den ville containerens uv sync overskrevet .venv som den
  kjørende tjenesten bruker.

/run/user/1000 er montert inn, så pw-play i containeren spiller på vertens
anlegg. Det er verifisert; kommenter ut linja hvis du bare skal teste UI.

Docker er bare for testing. På en ekte maskin kjører appen nativt med
install.sh, fordi lyden skal ut på maskinens egen lydutgang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 15:44:19 +02:00
d6eb811ee5 Pluggbar autentisering: PIN, JWT og OIDC
Dagens «auth» var én delt PIN i et X-Pin-header, sammenlignet med !=, og
den sa ingenting om hvem som sendte et utrop – køen viste bare IP-adressen.
Crew har et eget adminpanel som skal bli identitetsleverandør.

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

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

- pin   X-Pin som før, men med hmac.compare_digest istedenfor ==. Rolle
        admin som standard, så eksisterende oppsett oppfører seg likt.
        Beholdt bevisst: den virker uten nett, og det er nettopp da man
        trenger å rope ut noe.
- jwt   Authorization: Bearer. HS256 med delt hemmelighet, eller
        RS256/ES256 mot panelets JWKS. Krever exp – et token uten
        utløpstid er en evig nøkkel. Roller fra et konfigurerbart claim,
        som tåler liste, streng, kommaliste og punktsti.
- oidc  Autorisasjonskode med PKCE. Discovery hentes først ved første
        innlogging, så tjenesten starter selv om panelet er nede.

En leverandør er aktiv når den står i auth.providers *eller* når den er
ferdig konfigurert. Derfor slår ANNOUNCER_PIN=1234 alene fortsatt på PIN,
slik det har virket til nå. Er ingen aktiv, er alt åpent – som før.

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

401 betyr «vi vet ikke hvem du er, logg inn», 403 «vi vet hvem du er, men
rollen din får ikke dette». Et gyldig token uten roller gir 403, ikke 401 –
å logge inn på nytt ville ikke hjulpet.

Samtidig:

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

57 tester, de fleste på auth, inkludert hele OIDC-runden mot en stubbet
leverandør. Det er delen man ikke kan se etter med øyet: at en PIN virker
ser du med en gang, at et utløpt token blir avvist ser du aldri før det
smeller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 15:43:35 +02:00
87066bb8c2 Les konfigurasjon fra config.toml, med env-overstyring
Alt som gjorde appen «Sauda Gathering» – tittel, undertittel, aksentfarge,
hurtigfraser – var hardkodet i index.html. Dette legger grunnlaget for å
flytte det ut, så samme kode kan brukes på et annet arrangement uten å
redigere HTML.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 15:42:52 +02:00
f191fb2b90 Døp om sg-announcer til announcer
Appen var navngitt etter Sauda Gathering, men det er ingenting
arrangementsspesifikt i selve koden. Navnet står igjen i tjenesten,
install.sh og pyproject.

- install.sh skriver nå announcer.service, og rydder bort en gammel
  sg-announcer.service først. Uten det slåss to enheter om port 8090
  når skriptet kjøres på en maskin som har den gamle.
- Enhetsfila får chmod 600; PIN-en står i klartekst i den.
- src/sg_announcer/ er slettet. Det var en uv-init-stub som bare printet
  «Hello from sg-announcer!», pekt på av [project.scripts], og som ingen
  har kalt noen gang.
- [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.

Repoet i Forgejo og mappa er døpt om tilsvarende.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 15:42:36 +02:00
18 changed files with 2831 additions and 149 deletions

7
.gitignore vendored
View file

@ -2,3 +2,10 @@
cache/ cache/
voices/*.onnx* voices/*.onnx*
__pycache__/ __pycache__/
config.toml
logs/
instance/
.pytest_cache/
.docker-data/
# onnxruntime slenger fra seg denne når telemetri-ID ikke kan lagres
:memory:.ses

177
AGENTS.md
View file

@ -1,39 +1,165 @@
# sg-announcer – utrop på høyttaleranlegget (Sauda Gathering) # announcer – utrop på høyttaleranlegget
Liten Flask-app: tekst inn i nettleseren → forhåndslytt → «Send ut på Liten Flask-app: tekst inn i nettleseren → forhåndslytt → «Send ut på anlegget»
anlegget» spiller lyden lokalt på **denne** maskinen med `pw-play`. TTS er spiller lyden lokalt på **denne** maskinen med `pw-play`. TTS er edge-tts
Piper (`piper-tts`, onnx, helt lokalt). Alt ligger i to filer: (nevrale stemmer, krever nett) med Piper som lokal reserve.
`announcer.py` (server + kø) og `index.html` (sida).
Dokumentasjon for Kent: Athena-notatet Appen er generisk: tittel, undertittel, aksentfarge og hurtigfraser kommer fra
`30 Ressurser/Teknologi/Sauda Gathering - announcer.md`. Oppdater det, ikke `config.toml`, ikke fra HTML-en. Den het `sg-announcer` og var skrevet for Sauda
denne fila, når noe som er verdt å huske endres. Gathering fram til oktober 2026.
Dokumentasjon for Kent: Athena-notatet `30 Ressurser/Teknologi/Announcer.md`.
Oppdater det, ikke denne fila, når noe som er verdt å huske endres.
Filer:
| Fil | Hva |
|---|---|
| `announcer.py` | server, kø, TTS, jingler, ruter |
| `auth.py` | `Principal`, leverandørene, OIDC-rutene |
| `config.py` | `config.toml` + miljøvariabler, standardverdier |
| `index.html` | hele sida, inline CSS/JS, ingen byggesteg |
| `config.example.toml` | kommentert mal; kopier til `config.toml` |
| `docs/crew-integration.md` | kontrakten mot crew-panelet |
| `tests/test_auth.py` | `uv run pytest` |
| `compose.yaml` + `Dockerfile` | testoppsett, se under. Ikke produksjon |
| `docker/dummy_idp.py` | falsk crew-panel, så OIDC kan prøves |
## Kjøring ## Kjøring
Tjenesten er en systemd-brukerenhet: `~/.config/systemd/user/sg-announcer.service` Tjenesten er en systemd-brukerenhet: `~/.config/systemd/user/announcer.service`
(enabled, port 8090). `systemctl --user restart sg-announcer` etter endringer (enabled, port 8090). `systemctl --user restart announcer` etter endringer i
i koden. Manuelt: `uv run announcer.py --port 8090`. 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 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 NetBird (`wt0`) skrur det på ufw med deny incoming og åpner 22, 22022 og porten
på `wt0`. Kan kun på `wt0`. Kan kjøres på nytt, og rydder da bort en gammel
kjøres på nytt. Avspilling bruker `pw-play` når PipeWire kjører, ellers `sg-announcer.service` hvis den ligger der. Avspilling bruker `pw-play` når
`paplay` (`player_cmd()`), så Mint 21 med PulseAudio virker også. PipeWire kjører, ellers `paplay` (`player_cmd()`), så Mint 21 med PulseAudio
virker også.
Miljøvariabler (settes i enheten): `ANNOUNCER_PORT`, `ANNOUNCER_PIN` (tom = ### Omdøpingen: ett punkt gjenstår
ingen PIN), `ANNOUNCER_SINK` (pw-play `--target`; tom = standard utgang).
Mappa er flyttet til `~/Work/announcer`, enheten heter `announcer.service`, og
remote peker på `https://git.1x1.no/kent/announcer.git`. **Men repoet i Forgejo
heter `annuoncer`** – en skrivefeil. Rett navnet i Forgejo, så virker remote;
Forgejo legger selv inn en omdirigering fra det gamle navnet.
Flytter du mappa igjen: `.venv` har absolutte stier i `pyvenv.cfg` og i
shebangen til hvert konsollskript, så `uv sync` alene er ikke nok. Da må
`rm -rf .venv && uv sync` til, og tjenesten startes om.
## Testing i Docker
`docker compose up --build` gir to containere:
| | |
|---|---|
| `http://localhost:8091` | announcer med PIN, JWT **og** OIDC slått på (`docker/config.test.toml`) |
| `http://localhost:9000` | `docker/dummy_idp.py`, som later som den er crew-panelet |
Port 8091 med vilje, så den ikke kolliderer med tjenesten på 8090.
Dummy-IdP-en finnes fordi crew-panelet ikke har OIDC ennå: uten den kan
OIDC-veien ikke prøves i det hele tatt. Den kjører en ekte
autorisasjonskode-runde med PKCE (og avviser feil `code_verifier`), signerer
`id_token` med klienthemmeligheten, og lar deg velge hvem du logger inn som –
Kari (`crew`), Kent (`admin`) eller Ola (ingen roller, skal gi 403, ikke 401).
`http://localhost:9000/token?who=kari&go=1` logger deg rett inn via
token-lenka, slik panelet skal gjøre.
Merk i `docker/config.test.toml`: `authorization_endpoint` peker på
`localhost:9000` fordi nettleseren skal dit, mens `token_endpoint` peker på
`idp:9000` fordi announcer selv snakker med den inne i compose-nettet. Delt
horisont, og samtidig en test av at manuelle endepunkter virker når et panel
mangler discovery.
Lyd: `/run/user/1000` er montert inn, så `pw-play` i containeren spiller på
vertens anlegg. Kommenter ut den linja i `compose.yaml` hvis du bare skal
teste innlogging.
`UV_PROJECT_ENVIRONMENT=/opt/venv` i bildet er ikke pynt: `/app` er et
bind-mount av repoet, og uten det ville containerens `uv sync` overskrevet
`.venv` som den kjørende tjenesten bruker.
## Konfigurasjon
Tre lag, siste vinner: `DEFAULTS` i `config.py` → `config.toml` →
`ANNOUNCER_*`-miljøvariabler. **Uten `config.toml` virker alt**, med de gamle
standardverdiene og uten innlogging; det er med vilje, og
`test_uten_konfig_er_alt_apent` passer på det.
`config.toml` er gitignorert. `config.example.toml` er malen, og den er
kommentert – les den framfor å gjette.
Hemmeligheter settes som miljøvariabler i enheten, ikke i `config.toml`:
`ANNOUNCER_PIN`, `ANNOUNCER_JWT_SECRET`, `ANNOUNCER_OIDC_CLIENT_SECRET`,
`ANNOUNCER_SECRET_KEY`. Hele tabellen over variabler står i `ENV` i `config.py`;
den er eneste sted en ny variabel legges til.
## Autentisering
Modellen er én datatype og én funksjon: `Principal` (hvem + roller +
rettigheter) og `require(action)` som enten gir deg den eller stopper
forespørselen. Handlingene er `view`, `preview`, `announce`, `stop`, `clear`.
Tre leverandører, prøvd i rekkefølge, alle valgfrie:
- **pin** – `X-Pin`-header, som før. `hmac.compare_digest`, ikke `==`.
Får rollen `admin` som standard, så gammelt oppsett oppfører seg som før.
Virker uten nett, derfor beholdt.
- **jwt** – `Authorization: Bearer <jwt>` fra crew-panelet. HS256 med delt
hemmelighet, eller RS256/ES256 mot panelets JWKS (PyJWT cacher nøklene).
Krever `exp`; roller leses av et konfigurerbart claim.
- **oidc** – autorisasjonskode med PKCE. `/auth/login` → panelet →
`/auth/callback`. Discovery hentes først ved første innlogging, så tjenesten
starter selv om panelet er nede.
**En leverandør er aktiv når den står i `auth.providers` *eller* når den er
ferdig konfigurert.** Derfor slår `ANNOUNCER_PIN=1234` alene fortsatt på PIN.
Er ingen aktiv, er alt åpent.
`/auth/token?t=<jwt>` er broa mens panelet mangler OIDC: panelet lenker hit med
et kortlevd token, announcer setter sesjonscookie. Se
`docs/crew-integration.md` – det er kontrakten panelet skal oppfylle.
Roller → handlinger står i `[roles]` i `config.toml`. `"*"` betyr alt.
Rettighetene slås opp på nytt per forespørsel, så en endring der slår inn uten
at noen må logge inn igjen; nye `[auth.*]`-verdier krever omstart.
401 betyr «vi vet ikke hvem du er, logg inn», 403 betyr «vi vet hvem du er, men
rollen din får ikke dette». Et gyldig token uten roller gir 403, ikke 401 – å
logge inn på nytt ville ikke hjulpet.
Sesjonsnøkkelen genereres én gang og lagres i `instance/secret_key` (0600).
Uten den logges alle ut ved hver `systemctl restart`. `SESSION_COOKIE_SECURE`
settes bare når `server.behind_tls` er sann – appen kjører på rå HTTP over LAN
og NetBird, og med Secure på ville cookien aldri blitt sendt.
## Hvordan det henger sammen ## 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/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 - `/api/announce` legger meldingen i én FIFO-kø; en bakgrunnstråd spiller én om
om gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear` gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear`
tømmer køen. 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 - 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. 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. - 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`/ - Jingler: de innebygde (`BUILTIN_CHIMES`) genereres i Python med `_tone`/
`_mix`/`_overlay`, ingen lydfiler. Egne filer i `chimes/` (wav/mp3/ogg/flac) `_mix`/`_overlay`, ingen lydfiler. Egne filer i `chimes/` (wav/mp3/ogg/flac)
dukker opp i menyen som `file:<stem>`; ffmpeg kutter til 6 s, normaliserer dukker opp i menyen som `file:<stem>`; ffmpeg kutter til 6 s, normaliserer
@ -65,6 +191,17 @@ Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI
åpnes eksplisitt for LAN-et (`sudo ufw allow from 10.20.70.0/23 to any port 8090 proto tcp`). å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 - `pkill -f announcer.py` fra et skall som selv har `announcer.py` i
kommandolinja dreper skallet. Bruk `systemctl --user`. kommandolinja dreper skallet. Bruk `systemctl --user`.
- `player = Player()` står på modulnivå, så avspillingstråden starter ved
`import announcer` – også i `install.sh`, som importerer modulen for å laste
ned stemmer. Harmløst, men greit å vite.
- `tests/conftest.py` nuller ut alle `ANNOUNCER_*`-variabler før hver test.
Uten den arver testene maskinens miljø – systemd-enheten setter
`ANNOUNCER_PORT`, og compose setter JWT- og OIDC-hemmeligheter, noe som
gjorde 28 tester røde i containeren med feilmeldinger som ikke pekte på noe
galt.
- `pyproject.toml` har `[tool.uv] package = false`. Appen er løse `.py`-filer
som kjøres på plass, ikke en pakke; uten den vil `uv sync` bygge et wheel og
klage på at `announcer/__init__.py` mangler.
- Piper 1.8 bruker `SynthesisConfig(length_scale=…)`; lavere = raskere. - Piper 1.8 bruker `SynthesisConfig(length_scale=…)`; lavere = raskere.
- Forhåndslytt svarer 502 med feilmelding hvis edge-tts feiler (ingen nett). - Forhåndslytt svarer 502 med feilmelding hvis edge-tts feiler (ingen nett).
Køen markerer meldingen som `failed`. Bytt til en lokal reserve-stemme. Køen markerer meldingen som `failed`. Bytt til en lokal reserve-stemme.

31
Dockerfile Normal file
View file

@ -0,0 +1,31 @@
# Testbilde for announcer. Ikke ment for produksjon – på en ekte maskin
# kjører appen nativt med install.sh, fordi lyden skal ut på maskinens egen
# lydutgang og det er vondt å få til gjennom et containerlag.
#
# Her er poenget å kunne prøve innlogging (PIN, JWT, OIDC) uten å røre
# tjenesten som kjører, og uten sudo eller brannmurendringer.
FROM python:3.13-slim
# ffmpeg trengs av edge_pcm() og av egne jinglefiler. pw-play/paplay er bare
# nyttig hvis vertens PipeWire-socket monteres inn; uten den feiler selve
# avspillingen, men resten av appen virker.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \
&& (apt-get install -y --no-install-recommends pipewire-bin pulseaudio-utils || true) \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Venv utenfor /app: /app er et bind-mount av repoet, og en .venv bygget her
# ville ellers overskrevet den verten bruker til den kjørende tjenesten.
ENV UV_PROJECT_ENVIRONMENT=/opt/venv \
UV_COMPILE_BYTECODE=1 \
PATH=/opt/venv/bin:$PATH \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen
EXPOSE 8090
CMD ["python", "announcer.py", "--host", "0.0.0.0", "--port", "8090"]

View file

@ -1,16 +1,19 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""SG Announcer – tekst inn, forhåndslytt i nettleseren, les ut på PC-ens lydutgang. """Announcer – tekst inn, forhåndslytt i nettleseren, les ut på PC-ens lydutgang.
Kjøres på PC-en som er koblet til høyttaleranlegget. Alle på nettet kan åpne Kjøres på PC-en som er koblet til høyttaleranlegget. Selve avspillingen skjer
sida og sende meldinger; selve avspillingen skjer alltid lokalt på denne alltid lokalt på denne maskinen via pw-play (PipeWire) eller paplay.
maskinen via pw-play (PipeWire).
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 from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import io import json
import logging import logging
import math import math
import os import os
@ -26,12 +29,17 @@ from pathlib import Path
from flask import Flask, Response, abort, jsonify, request, send_file from flask import Flask, Response, abort, jsonify, request, send_file
import auth
import config
from auth import require
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
VOICE_DIR = Path(os.environ.get("ANNOUNCER_VOICES", HERE / "voices")) CFG = config.load()
CACHE_DIR = Path(os.environ.get("ANNOUNCER_CACHE", HERE / "cache")) VOICE_DIR = CFG.path("voices")
PIN = os.environ.get("ANNOUNCER_PIN", "").strip() CACHE_DIR = CFG.path("cache")
SINK = os.environ.get("ANNOUNCER_SINK", "").strip() # pw-play --target LOG_PATH = CFG.path("log")
MAX_CHARS = 600 SINK = str(CFG.get("audio", "sink", "")).strip() # pw-play --target
MAX_CHARS = int(CFG.app["max_chars"])
# Stemmene som tilbys i menyen. To motorer: # Stemmene som tilbys i menyen. To motorer:
# edge – Microsofts nevrale stemmer via edge-tts (gratis, krever internett, # 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"), _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} VOICES: dict[str, dict] = {v["id"]: v for v in VOICES_LIST}
DEFAULT_VOICE = "finn"
EDGE_RATE = 24000 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") log = logging.getLogger("announcer")
app = Flask(__name__, static_folder=None) 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. # (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", def _tone(rate: int, freq: float, dur: float, vol: float = 0.35, wave_: str = "sine",
@ -305,17 +318,23 @@ class Item:
voice: str voice: str
speed: float speed: float
chime: str 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) created: float = field(default_factory=time.time)
status: str = "queued" # queued | playing | done | stopped | failed status: str = "queued" # queued | playing | done | stopped | failed
error: str = "" error: str = ""
def public(self) -> dict: def public(self, *, show_ip: bool = False) -> dict:
return { return {
"id": self.id, "id": self.id,
"text": self.text, "text": self.text,
"voice": VOICES[self.voice]["name"] if self.voice in VOICES else self.voice, "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, "created": self.created,
"status": self.status, "status": self.status,
"error": self.error, "error": self.error,
@ -348,10 +367,16 @@ class Player:
self.seq = 0 self.seq = 0
threading.Thread(target=self._loop, daemon=True, name="player").start() 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: with self.lock:
self.seq += 1 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) self.history.append(item)
del self.history[:-50] del self.history[:-50]
self.q.put(item) self.q.put(item)
@ -373,12 +398,13 @@ class Player:
item.status = "stopped" item.status = "stopped"
self.stop() self.stop()
def snapshot(self) -> dict: def snapshot(self, *, show_ip: bool = False) -> dict:
with self.lock: with self.lock:
return { return {
"current": self.current.public() if self.current else None, "current": self.current.public(show_ip=show_ip) if self.current else None,
"queued": [i.public() for i in self.history if i.status == "queued"], "queued": [i.public(show_ip=show_ip) 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], "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: def _loop(self) -> None:
@ -409,6 +435,9 @@ class Player:
with self.lock: with self.lock:
self.current = None self.current = None
self.proc = 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() player = Player()
@ -419,9 +448,34 @@ player = Player()
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def check_pin() -> None: MAX_LOG_BYTES = 5_000_000
if PIN and request.headers.get("X-Pin", "") != PIN: _log_lock = threading.Lock()
abort(401)
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]: def parse_request() -> tuple[str, str, float, str]:
@ -439,9 +493,9 @@ def parse_request() -> tuple[str, str, float, str]:
except (TypeError, ValueError): except (TypeError, ValueError):
abort(400, "Ugyldig hastighet") abort(400, "Ugyldig hastighet")
speed = max(0.6, min(1.6, speed)) speed = max(0.6, min(1.6, speed))
chime = data.get("chime", "dingdong") chime = data.get("chime", DEFAULT_CHIME)
if chime is True: if chime is True:
chime = "dingdong" chime = DEFAULT_CHIME
elif chime is False or chime is None: elif chime is False or chime is None:
chime = "none" chime = "none"
chime = str(chime) chime = str(chime)
@ -456,58 +510,86 @@ def index() -> Response:
@app.get("/api/config") @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( 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], "voices": [{"id": v["id"], "name": v["name"], "group": v["group"], "engine": v["engine"]} for v in VOICES_LIST],
"default_voice": DEFAULT_VOICE, "default_voice": DEFAULT_VOICE,
"chimes": list_chimes(), "chimes": list_chimes(),
"default_chime": "dingdong", "default_chime": DEFAULT_CHIME,
"max_chars": MAX_CHARS, "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") @app.post("/api/preview")
def preview() -> Response: def preview() -> Response:
check_pin() me = require("preview")
text, voice, speed, chime = parse_request() text, voice, speed, chime = parse_request()
try: try:
path = synthesize(text, voice, speed, chime) path = synthesize(text, voice, speed, chime)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log.exception("syntese feilet") 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.") 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) return send_file(path, mimetype="audio/wav", max_age=3600)
@app.post("/api/announce") @app.post("/api/announce")
def announce() -> Response: def announce() -> Response:
check_pin() me = require("announce")
text, voice, speed, chime = parse_request() text, voice, speed, chime = parse_request()
sender = request.remote_addr or "?" sender = request.remote_addr or "?"
item = player.enqueue(text, voice, speed, chime, sender) item = player.enqueue(text, voice, speed, chime, sender, me)
log.info("kø #%d fra %s: %s", item.id, sender, text) log.info("kø #%d fra %s (%s): %s", item.id, item.user or sender, sender, text)
return jsonify(item.public()) 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") @app.post("/api/stop")
def stop() -> Response: def stop() -> Response:
check_pin() me = require("stop")
player.stop() player.stop()
audit("stop", **who(me))
return jsonify({"ok": True}) return jsonify({"ok": True})
@app.post("/api/clear") @app.post("/api/clear")
def clear() -> Response: def clear() -> Response:
check_pin() me = require("clear")
player.clear() player.clear()
audit("clear", **who(me))
return jsonify({"ok": True}) return jsonify({"ok": True})
@app.get("/api/status") @app.get("/api/status")
def status() -> Response: 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) @app.errorhandler(400)
@ -522,19 +604,40 @@ def bad_gateway(e): # noqa: ANN001
@app.errorhandler(401) @app.errorhandler(401)
def unauthorized(e): # noqa: ANN001 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: def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--host", default="0.0.0.0") ap.add_argument("--host", default=CFG.get("server", "host", "0.0.0.0"))
ap.add_argument("--port", type=int, default=int(os.environ.get("ANNOUNCER_PORT", 8090))) ap.add_argument("--port", type=int, default=int(CFG.get("server", "port", 8090)))
args = ap.parse_args() args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("werkzeug").setLevel(logging.WARNING) logging.getLogger("werkzeug").setLevel(logging.WARNING)
ensure_voices() ensure_voices()
load_voice("no_NO-talesyntese-medium") # varm opp reserven 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) app.run(host=args.host, port=args.port, threaded=True)

642
auth.py Normal file
View file

@ -0,0 +1,642 @@
#!/usr/bin/env python3
"""Pluggbar autentisering for announcer.
Hele modellen er én datatype og én funksjon:
Principal – hvem dette er, hvilke roller, hva rollene får gjøre
require(action) – hent den, eller stopp forespørselen med 401/403
Rundt det ligger leverandører som prøves i rekkefølge. Hver av dem svarer på
én forespørsel med «dette er brukeren», «jeg fant ingen legitimasjon av min
type» (None) eller AuthError («fant noe, men det var ugyldig»):
pin – X-Pin-header, én delt kode. Virker uten nett, derfor beholdt.
jwt – Authorization: Bearer <jwt> fra crew-panelet. HS256 med delt
hemmelighet, eller RS256/ES256 mot panelets JWKS.
oidc – autorisasjonskode med PKCE mot crew-panelet; nettleseren logger
inn der og får en sesjonscookie her.
Er ingen leverandør konfigurert, er alt åpent – nøyaktig som før. Det er med
vilje: tjenesten skal kunne settes opp på en kveld uten identitetsleverandør.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import json
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from flask import Blueprint, abort, g, redirect, request, session, url_for
from config import ACTIONS, Config
log = logging.getLogger("announcer.auth")
SESSION_KEY = "principal"
NAME_CLAIMS = ("name", "preferred_username", "nickname", "given_name", "email")
class AuthError(Exception):
"""Legitimasjon fulgte med, men den holdt ikke."""
# --------------------------------------------------------------------------
# Principal
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Principal:
subject: str # stabil id: "pin", "jwt:4f2a", "oidc:kent"
name: str # vises i køen og i loggen
roles: tuple[str, ...]
method: str # none | anonymous | pin | jwt | oidc
permissions: frozenset[str]
@property
def anonymous(self) -> bool:
return self.method in ("none", "anonymous")
def can(self, action: str) -> bool:
return action in self.permissions
def public(self) -> dict[str, Any]:
return {
"subject": self.subject,
"name": self.name,
"roles": list(self.roles),
"method": self.method,
"can": sorted(self.permissions),
}
def _claim(claims: dict, path: str) -> Any:
"""Henter et claim, også nestet: "realm_access.roles"."""
cur: Any = claims
for part in path.split("."):
if not isinstance(cur, dict):
return None
cur = cur.get(part)
return cur
def _roles(value: Any) -> tuple[str, ...]:
"""Roller kommer som liste, streng eller mellomromsdelt streng."""
if isinstance(value, str):
return tuple(v for v in value.replace(",", " ").split() if v)
if isinstance(value, (list, tuple, set)):
return tuple(str(v).strip() for v in value if str(v).strip())
return ()
def _display_name(claims: dict, name_claim: str) -> str:
if name_claim:
value = _claim(claims, name_claim)
if value:
return str(value)
for key in NAME_CLAIMS:
if claims.get(key):
return str(claims[key])
return str(claims.get("sub") or "ukjent")
# --------------------------------------------------------------------------
# JWT-verifisering, delt av jwt- og oidc-leverandøren
# --------------------------------------------------------------------------
class TokenVerifier:
"""Verifiserer signatur, iss, aud og exp. JWKS caches av PyJWT selv."""
def __init__(self, section: dict[str, Any]) -> None:
self.issuer = str(section.get("issuer", "")).strip()
self.audience = str(section.get("audience", "")).strip()
self.secret = str(section.get("secret", "")).strip()
self.jwks_url = str(section.get("jwks_url", "")).strip()
self.leeway = int(section.get("leeway", 30))
self.verify_signature = bool(section.get("verify_signature", True))
# Tom liste: utled av hvordan nøkkelen kommer. Delt hemmelighet er
# symmetrisk (HS256), JWKS er asymmetrisk (RS256/ES256).
algs = [str(a) for a in section.get("algorithms") or []]
self.algorithms = algs or (["RS256", "ES256"] if self.jwks_url else ["HS256"])
self._jwks: Any = None
def _signing_key(self, token: str) -> Any:
if not self.jwks_url:
return self.secret
if self._jwks is None:
import jwt # lazy: PIN-oppsett skal ikke trenge pyjwt
self._jwks = jwt.PyJWKClient(self.jwks_url, cache_keys=True, lifespan=3600)
return self._jwks.get_signing_key_from_jwt(token).key
def verify(self, token: str) -> dict:
import jwt
if not self.verify_signature:
# Bare for et crew-panel bak en proxy som alt har validert tokenet.
return jwt.decode(token, options={"verify_signature": False})
if not (self.secret or self.jwks_url):
raise AuthError("JWT er ikke konfigurert")
try:
return jwt.decode(
token,
self._signing_key(token),
algorithms=self.algorithms,
audience=self.audience or None,
issuer=self.issuer or None,
leeway=self.leeway,
options={
"verify_aud": bool(self.audience),
"verify_iss": bool(self.issuer),
"require": ["exp"],
},
)
except jwt.ExpiredSignatureError as e:
raise AuthError("Tokenet er utløpt") from e
except jwt.InvalidAudienceError as e:
raise AuthError("Tokenet gjelder en annen tjeneste") from e
except jwt.InvalidTokenError as e:
raise AuthError(f"Ugyldig token ({e})") from e
except urllib.error.URLError as e:
raise AuthError(f"Fikk ikke hentet nøkler fra {self.jwks_url} ({e})") from e
# --------------------------------------------------------------------------
# Leverandører
# --------------------------------------------------------------------------
class Provider:
name = ""
def authenticate(self, req) -> Principal | None: # noqa: ANN001
return None
def describe(self) -> dict[str, Any]:
return {"method": self.name}
class PinProvider(Provider):
name = "pin"
def __init__(self, cfg: Config) -> None:
s = cfg.section("auth.pin")
self.pin = str(s.get("pin", "")).strip()
self.role = str(s.get("role") or "admin")
self.label = str(s.get("label") or "PIN")
self.hint = str(s.get("hint", ""))
self.cfg = cfg
def authenticate(self, req) -> Principal | None: # noqa: ANN001
given = req.headers.get("X-Pin", "").strip()
if not given:
auth = req.headers.get("Authorization", "")
if auth[:4].lower() == "pin ":
given = auth[4:].strip()
if not given:
return None
# compare_digest, ikke ==, så svartiden ikke lekker hvor langt man kom.
if not hmac.compare_digest(given, self.pin):
raise AuthError("Feil PIN")
roles = (self.role,)
# En delt kode peker ikke på en person, og skal ikke late som. Vil man
# vite hvem som sendte hva, er det JWT eller OIDC man vil ha.
return Principal("pin", "delt PIN", roles, "pin", self.cfg.permissions(roles))
def describe(self) -> dict[str, Any]:
return {"method": "pin", "label": self.label, "hint": self.hint}
class JwtProvider(Provider):
name = "jwt"
def __init__(self, cfg: Config) -> None:
s = cfg.section("auth.jwt")
self.cfg = cfg
self.verifier = TokenVerifier(s)
self.role_claim = str(s.get("role_claim") or "roles")
self.name_claim = str(s.get("name_claim", ""))
self.default_role = str(s.get("default_role", ""))
self.session_seconds = int(s.get("session_seconds", 43200))
def principal(self, claims: dict, method: str = "jwt") -> Principal:
roles = _roles(_claim(claims, self.role_claim))
if not roles and self.default_role:
roles = (self.default_role,)
sub = str(claims.get("sub") or claims.get("email") or "ukjent")
return Principal(
f"{method}:{sub}",
_display_name(claims, self.name_claim),
roles,
method,
self.cfg.permissions(roles),
)
def authenticate(self, req) -> Principal | None: # noqa: ANN001
auth = req.headers.get("Authorization", "")
if auth[:7].lower() != "bearer ":
return None
token = auth[7:].strip()
if not token:
return None
return self.principal(self.verifier.verify(token))
class OidcProvider(Provider):
"""Autorisasjonskode med PKCE. Innloggingen skjer i nettleseren, så
authenticate() finner aldri noe i en header – sesjonen tar seg av resten."""
name = "oidc"
def __init__(self, cfg: Config) -> None:
s = cfg.section("auth.oidc")
self.cfg = cfg
self.issuer = str(s.get("issuer", "")).strip().rstrip("/")
self.client_id = str(s.get("client_id", "")).strip()
self.client_secret = str(s.get("client_secret", "")).strip()
self.scopes = [str(x) for x in s.get("scopes") or ["openid"]]
self.label = str(s.get("label") or "Logg inn")
self.role_claim = str(s.get("role_claim") or "roles")
self.name_claim = str(s.get("name_claim", ""))
self.default_role = str(s.get("default_role", ""))
self._manual = {
"authorization_endpoint": str(s.get("authorization_endpoint", "")).strip(),
"token_endpoint": str(s.get("token_endpoint", "")).strip(),
"jwks_uri": str(s.get("jwks_url", "")).strip(),
"end_session_endpoint": str(s.get("end_session_endpoint", "")).strip(),
}
self._meta: dict[str, Any] | None = None
self._verifier: TokenVerifier | None = None
# -- oppdagelse -------------------------------------------------------
def metadata(self) -> dict[str, Any]:
"""Discovery hentes først ved første innlogging, aldri ved oppstart –
tjenesten skal starte selv om crew-panelet er nede."""
if self._meta is not None:
return self._meta
meta = {k: v for k, v in self._manual.items() if v}
if not (meta.get("authorization_endpoint") and meta.get("token_endpoint")):
url = f"{self.issuer}/.well-known/openid-configuration"
try:
fetched = _get_json(url)
except Exception as e: # noqa: BLE001
raise AuthError(f"Fikk ikke hentet OIDC-oppsett fra {url} ({e})") from e
meta = {**fetched, **meta}
self._meta = meta
return meta
def verifier(self) -> TokenVerifier:
if self._verifier is None:
meta = self.metadata()
self._verifier = TokenVerifier(
{
"issuer": meta.get("issuer") or self.issuer,
"audience": self.client_id,
"secret": self.client_secret,
"jwks_url": meta.get("jwks_uri", ""),
"algorithms": [],
"verify_signature": bool(
self.cfg.get("auth.oidc", "verify_signature", True)
),
}
)
return self._verifier
# -- flyten -----------------------------------------------------------
def redirect_uri(self) -> str:
base = str(self.cfg.get("server", "public_url", "")).strip().rstrip("/")
if base:
return f"{base}/auth/callback"
return url_for("auth.callback", _external=True)
def authorize_url(self) -> str:
verifier = secrets.token_urlsafe(64)
state = secrets.token_urlsafe(24)
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
.rstrip(b"=")
.decode()
)
session["oidc_state"] = state
session["oidc_verifier"] = verifier
params = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri(),
"scope": " ".join(self.scopes),
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
endpoint = self.metadata()["authorization_endpoint"]
sep = "&" if "?" in endpoint else "?"
return f"{endpoint}{sep}{urllib.parse.urlencode(params)}"
def exchange(self, code: str, verifier: str) -> dict:
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri(),
"client_id": self.client_id,
"code_verifier": verifier,
}
headers = {}
if self.client_secret:
basic = base64.b64encode(
f"{self.client_id}:{self.client_secret}".encode()
).decode()
headers["Authorization"] = f"Basic {basic}"
try:
return _post_json(self.metadata()["token_endpoint"], data, headers)
except Exception as e: # noqa: BLE001
raise AuthError(f"Kodebyttet feilet ({e})") from e
def principal(self, claims: dict) -> Principal:
roles = _roles(_claim(claims, self.role_claim))
if not roles and self.default_role:
roles = (self.default_role,)
sub = str(claims.get("sub") or claims.get("email") or "ukjent")
return Principal(
f"oidc:{sub}",
_display_name(claims, self.name_claim),
roles,
"oidc",
self.cfg.permissions(roles),
)
def describe(self) -> dict[str, Any]:
return {"method": "oidc", "label": self.label, "login_url": "/auth/login"}
PROVIDERS = {"pin": PinProvider, "jwt": JwtProvider, "oidc": OidcProvider}
# --------------------------------------------------------------------------
# HTTP-hjelpere (stdlib, så vi slipper en ekstra avhengighet)
# --------------------------------------------------------------------------
def _get_json(url: str, timeout: int = 10) -> dict:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
return json.loads(r.read().decode())
def _post_json(url: str, form: dict, headers: dict | None = None, timeout: int = 10) -> dict:
body = urllib.parse.urlencode(form).encode()
req = urllib.request.Request(
url,
data=body,
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
**(headers or {}),
},
)
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
return json.loads(r.read().decode())
# --------------------------------------------------------------------------
# Authenticator
# --------------------------------------------------------------------------
class Authenticator:
def __init__(self, cfg: Config) -> None:
self.cfg = cfg
self.providers: list[Provider] = [
PROVIDERS[name](cfg) for name in cfg.active_providers()
]
self.enabled = bool(self.providers)
def provider(self, name: str) -> Provider | None:
return next((p for p in self.providers if p.name == name), None)
# -- anonym -----------------------------------------------------------
def anonymous(self) -> Principal:
"""Uten konfigurerte leverandører får anonyme alt – dagens oppførsel.
Med leverandører får de det [roles].anonymous sier, som er ingenting
som standard. Sett den til ["view"] om statusskjermen skal henge på
veggen uten innlogging."""
if not self.enabled:
# Tomt navn: da står køen og loggen som før, uten en «bruker» som
# i praksis bare betyr «hvem som helst».
return Principal("anonymous", "", ("anonymous",), "none", frozenset(ACTIONS))
roles = ("anonymous",)
return Principal("anonymous", "ikke innlogget", roles, "anonymous", self.cfg.permissions(roles))
# -- sesjon -----------------------------------------------------------
def from_session(self) -> Principal | None:
raw = session.get(SESSION_KEY)
if not isinstance(raw, dict):
return None
if raw.get("exp") and float(raw["exp"]) < time.time():
session.pop(SESSION_KEY, None)
return None
roles = tuple(str(r) for r in raw.get("roles", ()))
return Principal(
str(raw.get("subject", "?")),
str(raw.get("name", "?")),
roles,
str(raw.get("method", "jwt")),
# Rettigheter slås opp på nytt hver gang, så en endring i
# [roles] slår inn uten at folk må logge inn igjen.
self.cfg.permissions(roles),
)
def remember(self, p: Principal, seconds: int) -> None:
session.permanent = True
session[SESSION_KEY] = {
"subject": p.subject,
"name": p.name,
"roles": list(p.roles),
"method": p.method,
"exp": time.time() + seconds,
}
# -- oppslag ----------------------------------------------------------
def resolve(self) -> tuple[Principal, str]:
"""Returnerer (principal, feilmelding). Kaster ikke – /api/config skal
kunne spørre «hvem er jeg?» uten å få 401 i fleisen."""
p = self.from_session()
if p is not None:
return p, ""
for provider in self.providers:
try:
p = provider.authenticate(request)
except AuthError as e:
return self.anonymous(), str(e)
if p is not None:
return p, ""
return self.anonymous(), ""
def describe(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"methods": [p.describe() for p in self.providers],
"logout_url": "/auth/logout" if self.enabled else None,
}
_auth: Authenticator | None = None
def current() -> Principal:
"""Principalen for denne forespørselen, slått opp én gang."""
if not hasattr(g, "principal"):
assert _auth is not None, "auth.install() er ikke kalt"
g.principal, g.auth_error = _auth.resolve()
return g.principal
def error() -> str:
current()
return getattr(g, "auth_error", "")
def require(action: str) -> Principal:
"""Enten har denne brukeren lov, eller så stopper forespørselen her.
401 når vi ikke vet hvem det er (klienten skal logge inn), 403 når vi vet
hvem det er men rollen mangler handlingen.
"""
p = current()
if p.can(action):
return p
if p.anonymous:
abort(401, error() or _login_hint())
abort(403, f"Rollen din ({', '.join(p.roles) or 'ingen'}) har ikke lov til dette.")
def _login_hint() -> str:
assert _auth is not None
names = {p.name for p in _auth.providers}
if names == {"pin"}:
return "Feil eller manglende PIN."
if "oidc" in names:
return "Du må logge inn."
return "Innlogging kreves."
# --------------------------------------------------------------------------
# Oppsett og ruter
# --------------------------------------------------------------------------
def secret_key(cfg: Config) -> bytes:
"""Fast nøkkel, ellers logges alle ut ved hver restart av tjenesten."""
configured = str(cfg.get("server", "secret_key", "")).strip()
if configured:
return configured.encode()
path = cfg.path("state") / "secret_key"
if path.is_file():
return path.read_bytes()
path.parent.mkdir(parents=True, exist_ok=True)
key = secrets.token_bytes(32)
path.write_bytes(key)
path.chmod(0o600)
log.info("lagde ny sesjonsnøkkel i %s", path)
return key
bp = Blueprint("auth", __name__)
@bp.get("/auth/login")
def login():
oidc = _auth.provider("oidc") if _auth else None
if not isinstance(oidc, OidcProvider):
abort(404, "OIDC er ikke satt opp")
try:
return redirect(oidc.authorize_url())
except AuthError as e:
abort(502, str(e))
@bp.get("/auth/callback")
def callback():
oidc = _auth.provider("oidc") if _auth else None
if not isinstance(oidc, OidcProvider):
abort(404, "OIDC er ikke satt opp")
if err := request.args.get("error"):
abort(401, f"{err}: {request.args.get('error_description', '')}".strip(": "))
state = session.pop("oidc_state", None)
verifier = session.pop("oidc_verifier", None)
if not state or not verifier or request.args.get("state") != state:
abort(400, "Innloggingen kom ut av takt. Prøv på nytt.")
code = request.args.get("code", "")
if not code:
abort(400, "Ingen kode fra identitetsleverandøren")
try:
tokens = oidc.exchange(code, verifier)
id_token = tokens.get("id_token")
if not id_token:
abort(502, "Fikk ikke id_token fra identitetsleverandøren")
claims = oidc.verifier().verify(id_token)
except AuthError as e:
abort(401, str(e))
p = oidc.principal(claims)
ttl = int(claims.get("exp", 0) - time.time()) if claims.get("exp") else 0
_auth.remember(p, max(ttl, 3600))
log.info("innlogget %s (%s) via oidc", p.name, p.subject)
return redirect("/")
@bp.get("/auth/token")
def token_login():
"""Bro mens crew-panelet mangler OIDC: panelet lenker hit med et kortlevd
JWT, vi verifiserer det og setter sesjonscookien. Ett klikk, ingen PIN."""
provider = _auth.provider("jwt") if _auth else None
if not isinstance(provider, JwtProvider):
abort(404, "JWT er ikke satt opp")
raw = request.args.get("t") or request.args.get("token") or ""
if not raw.strip():
abort(400, "Mangler token")
try:
claims = provider.verifier.verify(raw.strip())
except AuthError as e:
abort(401, str(e))
p = provider.principal(claims)
ttl = int(claims.get("exp", 0) - time.time()) if claims.get("exp") else 0
_auth.remember(p, max(ttl, provider.session_seconds))
log.info("innlogget %s (%s) via token-lenke", p.name, p.subject)
return redirect("/")
@bp.get("/auth/logout")
def logout():
session.clear()
oidc = _auth.provider("oidc") if _auth else None
if isinstance(oidc, OidcProvider) and oidc._meta:
if end := oidc._meta.get("end_session_endpoint"):
return redirect(end)
return redirect("/")
def install(app, cfg: Config) -> Authenticator: # noqa: ANN001
"""Kobler autentisering på Flask-appen. Returnerer Authenticatoren."""
global _auth
_auth = Authenticator(cfg)
app.secret_key = secret_key(cfg)
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
# Secure bare bak TLS: på rå HTTP over LAN/NetBird ville cookien
# aldri blitt sendt, og ingen ville klart å logge inn.
SESSION_COOKIE_SECURE=bool(cfg.get("server", "behind_tls", False)),
)
app.register_blueprint(bp)
return _auth

45
compose.yaml Normal file
View file

@ -0,0 +1,45 @@
# Testoppsett, ikke produksjon: docker compose up --build
#
# http://localhost:8091 announcer (PIN + JWT + OIDC slått på)
# http://localhost:9000 dummy-IdP som later som den er crew-panelet
#
# Porten er 8091 med vilje, så den ikke kolliderer med tjenesten på 8090.
name: announcer-test
services:
announcer:
build: .
ports: ["8091:8090"]
user: "1000:1000"
environment:
ANNOUNCER_CONFIG: /app/docker/config.test.toml
# Buffer, logg og sesjonsnøkkel holdes utenfor repoet, så testing ikke
# blander seg med det den kjørende tjenesten eier.
ANNOUNCER_CACHE: /data/cache
ANNOUNCER_LOG: /data/logs/announcements.jsonl
ANNOUNCER_STATE: /data/instance
ANNOUNCER_VOICES: /app/voices
ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte
ANNOUNCER_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker
# Må peke dit nettleseren ser appen, ellers blir OIDC-redirecten feil.
ANNOUNCER_PUBLIC_URL: http://localhost:8091
XDG_RUNTIME_DIR: /run/user/1000
volumes:
- .:/app
- ./.docker-data:/data
# Lyd: gir containeren vertens PipeWire. Kommenter ut hvis du bare
# skal teste innlogging og UI – da feiler avspilling, men ikke annet.
- /run/user/1000:/run/user/1000
depends_on: [idp]
idp:
build: .
ports: ["9000:9000"]
user: "1000:1000"
environment:
IDP_ISSUER: http://localhost:9000
IDP_CLIENT_ID: announcer
IDP_CLIENT_SECRET: test-client-secret-kun-for-docker
volumes:
- .:/app
command: ["python", "docker/dummy_idp.py"]

123
config.example.toml Normal file
View file

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

275
config.py Normal file
View file

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

39
docker/config.test.toml Normal file
View file

@ -0,0 +1,39 @@
# Konfigurasjon for docker compose-testingen. Alle tre innloggingsmetodene er
# på samtidig, noe man nok ikke vil i virkeligheten, men her er poenget å
# kunne prøve dem.
#
# Hemmelighetene settes som miljøvariabler i compose.yaml, ikke her.
[app]
title = "Announcer Test"
subtitle = "Docker-oppsett – PIN, JWT og OIDC er alle slått på"
accent = "#f5a623"
quick_phrases = ["Dette er en test", "Pizzaen er kommet. Hent i kantina!"]
[auth]
providers = ["oidc", "jwt", "pin"]
[auth.pin]
pin = "1234"
role = "crew"
hint = "1234 i dette testoppsettet"
[auth.jwt]
issuer = "http://localhost:9000"
audience = "announcer"
role_claim = "roles"
[auth.oidc]
issuer = "http://localhost:9000"
client_id = "announcer"
label = "Logg inn med dummy-IdP"
# Delt horisont: nettleseren ser IdP-en som localhost:9000, mens announcer
# selv snakker med den som idp:9000 inne i compose-nettet. Derfor settes
# endepunktene manuelt istedenfor å bruke discovery.
authorization_endpoint = "http://localhost:9000/oauth/authorize"
token_endpoint = "http://idp:9000/oauth/token"
[roles]
anonymous = ["view"]
crew = ["view", "preview", "announce"]
admin = ["*"]

144
docker/dummy_idp.py Normal file
View file

@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Dummy identitetsleverandør for testing. Later som den er crew-panelet.
Crew-panelet har ikke OIDC ennå, så uten noe slikt kan ikke OIDC-veien prøves
i det hele tatt. Denne gjør det mulig: full autorisasjonskode-runde med PKCE,
og du velger selv hvilken rolle du vil logge inn med.
Den er *ikke* en IdP. Den signerer med klienthemmeligheten (HS256), lagrer
koder i minnet og spør ikke om passord. Bruk den bare bak compose.
"""
from __future__ import annotations
import base64
import hashlib
import os
import secrets
import time
import urllib.parse
import jwt
from flask import Flask, abort, jsonify, redirect, request
ISSUER = os.environ.get("IDP_ISSUER", "http://localhost:9000")
CLIENT_ID = os.environ.get("IDP_CLIENT_ID", "announcer")
CLIENT_SECRET = os.environ.get("IDP_CLIENT_SECRET", "test-client-secret-kun-for-docker")
# Folk man kan logge inn som. Rollenavnene må finnes i announcers [roles].
USERS = {
"kari": {"sub": "101", "name": "Kari Nordmann", "email": "kari@crew.test", "roles": ["crew"]},
"kent": {"sub": "1", "name": "Kent", "email": "kent@crew.test", "roles": ["admin"]},
"ola": {"sub": "102", "name": "Ola Utenfor", "email": "ola@crew.test", "roles": []},
}
app = Flask(__name__)
codes: dict[str, dict] = {}
@app.get("/.well-known/openid-configuration")
def discovery():
return jsonify({
"issuer": ISSUER,
"authorization_endpoint": f"{ISSUER}/oauth/authorize",
"token_endpoint": f"{ISSUER}/oauth/token",
"response_types_supported": ["code"],
"id_token_signing_alg_values_supported": ["HS256"],
"code_challenge_methods_supported": ["S256"],
})
@app.get("/oauth/authorize")
def authorize():
if request.args.get("client_id") != CLIENT_ID:
abort(400, "ukjent client_id")
for required in ("redirect_uri", "state", "code_challenge"):
if not request.args.get(required):
abort(400, f"mangler {required}")
if request.args.get("code_challenge_method") != "S256":
abort(400, "krever PKCE med S256")
who = request.args.get("who")
if who not in USERS:
# Ingen innloggingsside, bare en liste å trykke på.
rows = "".join(
f'<li><a href="?{urllib.parse.urlencode({**request.args, "who": k})}">'
f'<b>{v["name"]}</b> – roller: {", ".join(v["roles"]) or "ingen"}</a></li>'
for k, v in USERS.items()
)
return f"""<!doctype html><meta charset="utf-8"><title>Dummy-IdP</title>
<style>body{{font:16px/1.5 system-ui;background:#0e1116;color:#e6ebf2;max-width:560px;margin:60px auto;padding:0 16px}}
a{{color:#4fd1c5;text-decoration:none}} li{{margin:10px 0}} code{{color:#8a96a8}}</style>
<h1>Dummy-IdP</h1>
<p>Står for crew-panelet. Velg hvem du vil logge inn som:</p>
<ul>{rows}</ul>
<p><code>Ola har ingen roller – han skal få 403, ikke 401.</code></p>"""
code = secrets.token_urlsafe(24)
codes[code] = {
"user": who,
"challenge": request.args["code_challenge"],
"redirect_uri": request.args["redirect_uri"],
"exp": time.time() + 300,
}
sep = "&" if "?" in request.args["redirect_uri"] else "?"
params = urllib.parse.urlencode({"code": code, "state": request.args["state"]})
return redirect(f"{request.args['redirect_uri']}{sep}{params}")
@app.post("/oauth/token")
def token():
code = request.form.get("code", "")
entry = codes.pop(code, None) # engangsbruk
if not entry or entry["exp"] < time.time():
return jsonify({"error": "invalid_grant"}), 400
if request.form.get("redirect_uri") != entry["redirect_uri"]:
return jsonify({"error": "invalid_grant", "error_description": "redirect_uri stemmer ikke"}), 400
# PKCE: verifier skal hashe til challenge vi fikk i authorize.
verifier = request.form.get("code_verifier", "")
expected = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
if not verifier or expected != entry["challenge"]:
return jsonify({"error": "invalid_grant", "error_description": "PKCE stemmer ikke"}), 400
user = USERS[entry["user"]]
claims = {
"iss": ISSUER,
"aud": CLIENT_ID,
"iat": int(time.time()),
"exp": int(time.time()) + 3600,
**user,
}
return jsonify({
"access_token": secrets.token_urlsafe(16),
"token_type": "Bearer",
"expires_in": 3600,
"id_token": jwt.encode(claims, CLIENT_SECRET, algorithm="HS256"),
})
@app.get("/token")
def mint():
"""Lager et JWT til token-lenka og til curl, slik panelet skal gjøre.
http://localhost:9000/token?who=kari -> rått token
http://localhost:9000/token?who=kari&go=1 -> logger deg rett inn
"""
user = USERS.get(request.args.get("who", "kari"))
if not user:
abort(404, "ukjent bruker")
raw = jwt.encode(
{"iss": ISSUER, "aud": CLIENT_ID, "exp": int(time.time()) + 300, **user},
os.environ.get("ANNOUNCER_JWT_SECRET", "test-hemmelighet-kun-for-docker-ikke-bruk-ekte"),
algorithm="HS256",
)
if request.args.get("go"):
base = os.environ.get("ANNOUNCER_PUBLIC_URL", "http://localhost:8091")
return redirect(f"{base}/auth/token?t={raw}")
return raw, 200, {"Content-Type": "text/plain"}
if __name__ == "__main__":
app.run(host="0.0.0.0", port=9000, threaded=True)

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

@ -0,0 +1,204 @@
# Koble announcer til crew-panelet
Announcer har ingen brukerdatabase og skal ikke ha det. Den stoler på
crew-panelet og leser identitet og roller ut av det panelet sier. Dette
dokumentet er kontrakten: hva panelet må levere, og i hvilken rekkefølge det er
verdt å bygge det.
Panelet har i dag verken JWT eller OIDC. **Bygg JWT først** – det er noen titalls
linjer i panelet og dekker behovet. OIDC kan komme etterpå uten at noe i
announcer må skrives om; begge veier ender i samme kode.
---
## Trinn 1: JWT (anbefalt start)
Panelet signerer et kortlevd token som sier hvem brukeren er og hvilke roller
han har. Announcer verifiserer signaturen og stoler på innholdet.
### Hva tokenet skal inneholde
```json
{
"iss": "https://crew.example.no",
"aud": "announcer",
"sub": "142",
"name": "Kari Nordmann",
"roles": ["crew"],
"exp": 1760000000
}
```
| Claim | Krav | Merknad |
|---|---|---|
| `iss` | må stemme med `auth.jwt.issuer` | sjekkes bare hvis den er satt i config |
| `aud` | må stemme med `auth.jwt.audience` | bruk `"announcer"` |
| `sub` | påkrevd i praksis | stabil bruker-id; havner i loggen som `jwt:<sub>` |
| `exp` | **påkrevd** | announcer avviser tokens uten utløpstid |
| `name` | valgfri | vises i køen. Faller tilbake på `preferred_username`, `email`, så `sub` |
| `roles` | valgfri | se under |
`roles` kan være en liste (`["crew"]`), en streng (`"crew"`), mellomroms- eller
kommadelt (`"crew admin"`), og den kan ligge nestet – da peker du på den med
`role_claim = "realm_access.roles"`. Mangler den helt, får brukeren
`auth.jwt.default_role`, eller ingen rettigheter hvis den ikke er satt.
Rollenavnene betyr ingenting for announcer i seg selv; `[roles]` i `config.toml`
bestemmer hva de får lov til.
### Signering
To varianter. Begge virker, den andre er bedre:
**HS256 med delt hemmelighet** – enklest. Samme streng i panelet og i
announcer. Ulempen er at announcer dermed har en nøkkel som *kan lage* gyldige
tokens.
```toml
[auth.jwt]
issuer = "https://crew.example.no"
audience = "announcer"
# settes som ANNOUNCER_JWT_SECRET i systemd-enheten, ikke her
```
**RS256 med JWKS** – panelet signerer med sin private nøkkel og publiserer den
offentlige på `/.well-known/jwks.json`. Announcer henter den og cacher den en
time. Da kan announcer bare verifisere, aldri signere, og nøkkelrullering
skjer av seg selv.
```toml
[auth.jwt]
issuer = "https://crew.example.no"
audience = "announcer"
jwks_url = "https://crew.example.no/.well-known/jwks.json"
```
### Hvordan brukeren kommer inn
To måter, og panelet kan bruke begge:
**Lenke (det du vil ha for et crew-medlem).** Panelet lager en knapp «Åpne
announcer» som peker på
```
http://<announcer>:8090/auth/token?t=<jwt>
```
Announcer verifiserer tokenet, setter en sesjonscookie og sender brukeren til
forsida. Ett klikk, ingen PIN å huske. Lag tokenet kortlevd (`exp` et par
minutter fram) – det er bare en billett inn; sesjonen varer så lenge tokenets
`exp` sier, eller `session_seconds` hvis det er lengre.
**Header (for skript og integrasjoner).** Send tokenet på hver forespørsel:
```bash
curl -X POST http://announcer:8090/api/announce \
-H "Authorization: Bearer $JWT" \
-H 'Content-Type: application/json' \
-d '{"text":"Testmelding","voice":"finn"}'
```
### Minimum i panelet (Python-eksempel)
```python
import time, jwt # pyjwt
def announcer_url(user) -> str:
token = jwt.encode(
{
"iss": "https://crew.example.no",
"aud": "announcer",
"sub": str(user.id),
"name": user.full_name,
"roles": ["admin" if user.is_crew_lead else "crew"],
"exp": time.time() + 120,
},
ANNOUNCER_JWT_SECRET,
algorithm="HS256",
)
return f"http://announcer.lan:8090/auth/token?t={token}"
```
---
## Trinn 2: OIDC
Når panelet har fått `authorize`-, `token`- og gjerne `userinfo`-endepunkter,
kan announcer bli en vanlig OIDC-klient. Da logger brukeren inn i panelet i
nettleseren og trenger ingen lenke med token i.
Announcer bruker autorisasjonskode med PKCE (S256). Dette må registreres i
panelet:
| | |
|---|---|
| `client_id` | f.eks. `announcer` |
| `redirect_uri` | `http://<vert>:8090/auth/callback` – **eksakt**, inkludert port |
| `grant_type` | `authorization_code` |
| `scope` | `openid profile email` (eller bare `openid`) |
| PKCE | `S256` |
Announcer leser `{issuer}/.well-known/openid-configuration` ved første
innlogging. Har ikke panelet discovery, settes endepunktene direkte:
```toml
[auth.oidc]
issuer = "https://crew.example.no"
client_id = "announcer"
authorization_endpoint = "https://crew.example.no/oauth/authorize"
token_endpoint = "https://crew.example.no/oauth/token"
jwks_url = "https://crew.example.no/.well-known/jwks.json"
```
`client_secret` settes som `ANNOUNCER_OIDC_CLIENT_SECRET`. Rollene leses ut av
`id_token` på samme måte som over.
Står announcer bak en omvendt proxy, sett `server.public_url` så
`redirect_uri` blir riktig, og `server.behind_tls = true`.
---
## Roller og rettigheter
Fem handlinger. `config.toml` kobler rollenavn til dem:
| Handling | Hva den gir |
|---|---|
| `view` | se køen og hva som spilles nå |
| `preview` | forhåndslytte (koster en syntese) |
| `announce` | sende ut på anlegget |
| `stop` | stoppe det som spilles |
| `clear` | tømme køen (og se IP-adresser i køen) |
```toml
[roles]
anonymous = [] # ["view"] for en statusskjerm
crew = ["view", "preview", "announce"]
admin = ["*"]
```
Endrer du `[roles]` slår det inn ved neste forespørsel – ingen må logge inn på
nytt. (Nye `[auth.*]`-verdier krever omstart.)
## Hva announcer svarer
| Kode | Betyr | Hva klienten skal gjøre |
|---|---|---|
| 401 | vi vet ikke hvem du er | logg inn |
| 403 | vi vet hvem du er, rollen din får ikke dette | ikke prøv igjen |
`GET /api/config` svarer alltid 200 og forteller i `auth`-blokka hvilke metoder
som er aktive, hvem du er og hva du får gjøre. Det er den sida bruker for å
vise eller skjule knapper.
## Loggen
Hvert utrop havner i `logs/announcements.jsonl`, én JSON-linje per hendelse:
```json
{"ts":1760000000.12,"action":"announce","subject":"jwt:142","name":"Kari Nordmann",
"method":"jwt","ip":"10.20.70.55","id":7,"text":"Pizzaen er kommet","voice":"finn"}
```
`action` er `announce`, `played`, `stop` eller `clear`. Fila rullerer til
`.jsonl.1` ved 5 MB.

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>SG Announcer</title> <title>Announcer</title>
<style> <style>
:root { :root {
--bg: #0e1116; --panel: #171c24; --panel2: #1f2631; --line: #2a3340; --bg: #0e1116; --panel: #171c24; --panel2: #1f2631; --line: #2a3340;
@ -17,6 +17,11 @@
h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: .02em; } h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: .02em; }
h1 span { color: var(--accent); } h1 span { color: var(--accent); }
.sub { color: var(--muted); margin: 0 0 20px; font-size: 14px; } .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; } .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; } 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); textarea { width: 100%; min-height: 120px; resize: vertical; background: var(--panel2); color: var(--fg);
@ -27,8 +32,6 @@
.row > div { flex: 1 1 160px; } .row > div { flex: 1 1 160px; }
select, input[type=range] { width: 100%; } 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; } 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; } .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; .chip { background: var(--panel2); border: 1px solid var(--line); color: var(--fg); border-radius: 999px;
padding: 6px 12px; font-size: 13px; cursor: pointer; } padding: 6px 12px; font-size: 13px; cursor: pointer; }
@ -39,6 +42,7 @@
button.btn:disabled { opacity: .5; cursor: default; } button.btn:disabled { opacity: .5; cursor: default; }
button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; } 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; } 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 { width: 100%; margin-top: 12px; display: none; }
audio.show { display: block; } audio.show { display: block; }
.msg { margin-top: 10px; font-size: 14px; min-height: 1.4em; } .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; } .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 { 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 { 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 { 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 { 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 .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 .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); } 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; .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; } animation: pulse 1.2s infinite; vertical-align: middle; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .25 } } @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%; } #pinbox input { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; width: 100%; }
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
.hidden { display: none !important; }
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<h1>SG <span>Announcer</span></h1> <div class="top">
<p class="sub">Skriv meldingen, forhåndslytt, og send den ut på anlegget.</p> <div>
<h1 id="title"></h1>
<p class="sub" id="subtitle"></p>
</div>
<div class="who" id="who"></div>
</div>
<div class="card" id="loginbox">
<div id="loginbtns" class="actions" style="margin-top:0"></div>
</div>
<div class="card" id="pinbox"> <div class="card" id="pinbox">
<label for="pin">PIN</label> <label for="pin" id="pinlabel">PIN</label>
<input id="pin" type="password" inputmode="numeric" placeholder="Spør i crew-rommet"> <input id="pin" type="password" inputmode="numeric" autocomplete="current-password">
<div class="hint" id="pinhint"></div>
</div> </div>
<div class="card"> <div class="card">
<label for="text">Melding</label> <label for="text">Melding</label>
<textarea id="text" placeholder="F.eks. «Pizzaen er kommet. Hent i kantina!»" autofocus></textarea> <textarea id="text" autofocus></textarea>
<div class="counter"><span id="count">0</span> / <span id="max">600</span></div> <div class="counter"><span id="count">0</span> / <span id="max">600</span></div>
<div class="chips" id="chips"></div> <div class="chips" id="chips"></div>
@ -122,68 +138,165 @@
const $ = s => document.querySelector(s); const $ = s => document.querySelector(s);
const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime'); const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime');
const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce'); const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce');
let cfg = {}, previewedKey = null; let cfg = {}, can = [], previewedKey = null, pollTimer = 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.',
];
function key() { return JSON.stringify(payload()); } function key() { return JSON.stringify(payload()); }
function payload() { function payload() {
return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.value }; return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.value };
} }
function headers() { function headers(json) {
const h = { 'Content-Type': 'application/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'); const pin = localStorage.getItem('pin');
if (pin) h['X-Pin'] = pin; if (pin) h['X-Pin'] = pin;
return h; return h;
} }
function say(t, cls) { msg.textContent = t; msg.className = 'msg ' + (cls || ''); } function say(t, cls) { msg.textContent = t; msg.className = 'msg ' + (cls || ''); }
function may(action) { return can.includes(action); }
function updateAnnounce() { function updateAnnounce() {
btnAnnounce.disabled = !(previewedKey && previewedKey === key()); const ready = previewedKey && previewedKey === key();
btnAnnounce.title = btnAnnounce.disabled ? 'Forhåndslytt først' : ''; 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) { if (!r.ok) {
let e = r.statusText; try { e = (await r.json()).error || e; } catch {} let e = r.statusText, login = false;
throw new Error(e); 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; 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() { 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 = {}; 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]); } 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); 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; voice.value = localStorage.getItem('voice') || c.default_voice;
if (!voice.value) voice.value = cfg.default_voice; if (!voice.value) voice.value = c.default_voice;
for (const c of cfg.chimes) { for (const ch of c.chimes) {
const o = document.createElement('option'); o.value = c.id; o.textContent = c.name; chime.append(o); const o = document.createElement('option'); o.value = ch.id; o.textContent = ch.name; chime.append(o);
} }
chime.value = localStorage.getItem('chime') || cfg.default_chime; chime.value = localStorage.getItem('chime') || c.default_chime;
if (!chime.value) chime.value = cfg.default_chime; if (!chime.value) chime.value = c.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') || ''; } applyAuth(c.auth || {});
for (const q of QUICK) { poll();
const b = document.createElement('button'); b.className = 'chip'; b.textContent = q.slice(0, 32) + (q.length > 32 ? '…' : ''); pollTimer = setInterval(poll, 2000);
b.title = q; b.onclick = () => { text.value = q; text.dispatchEvent(new Event('input')); text.focus(); };
$('#chips').append(b);
}
poll(); 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(); }); text.addEventListener('input', () => { $('#count').textContent = text.value.length; updateAnnounce(); });
voice.addEventListener('change', () => { localStorage.setItem('voice', voice.value); updateAnnounce(); }); voice.addEventListener('change', () => { localStorage.setItem('voice', voice.value); updateAnnounce(); });
speed.addEventListener('input', () => { $('#speedval').textContent = parseFloat(speed.value).toFixed(2).replace(/0$/, '') + '×'; updateAnnounce(); }); speed.addEventListener('input', () => { $('#speedval').textContent = parseFloat(speed.value).toFixed(2).replace(/0$/, '') + '×'; updateAnnounce(); });
chime.addEventListener('change', () => { localStorage.setItem('chime', chime.value); updateAnnounce(); }); chime.addEventListener('change', () => { localStorage.setItem('chime', chime.value); updateAnnounce(); });
$('#pin').addEventListener('input', e => localStorage.setItem('pin', e.target.value));
btnPreview.addEventListener('click', async () => { btnPreview.addEventListener('click', async () => {
const p = payload(); const p = payload();
@ -195,10 +308,10 @@ btnPreview.addEventListener('click', async () => {
if (audio.src) URL.revokeObjectURL(audio.src); if (audio.src) URL.revokeObjectURL(audio.src);
audio.src = URL.createObjectURL(blob); audio.classList.add('show'); audio.src = URL.createObjectURL(blob); audio.classList.add('show');
await audio.play().catch(() => {}); await audio.play().catch(() => {});
previewedKey = JSON.stringify(p); updateAnnounce(); previewedKey = JSON.stringify(p);
say('Hør gjennom. Fornøyd? Send den ut.', 'ok'); say('Hør gjennom. Fornøyd? Send den ut.', 'ok');
} catch (e) { say(e.message, 'err'); } } catch (e) { onError(e); }
finally { btnPreview.disabled = false; } finally { btnPreview.disabled = false; updateAnnounce(); }
}); });
btnAnnounce.addEventListener('click', async () => { btnAnnounce.addEventListener('click', async () => {
@ -209,29 +322,40 @@ btnAnnounce.addEventListener('click', async () => {
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null; text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
audio.pause(); audio.classList.remove('show'); audio.pause(); audio.classList.remove('show');
poll(); 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'))); $('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(onError));
$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(e => say(e.message, 'err'))); $('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(onError));
const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø' }; 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 hhmm(ts) { return new Date(ts * 1000).toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' }); }
function li(it) { function li(it) {
const l = document.createElement('li'); l.className = it.status; const l = document.createElement('li'); l.className = it.status;
l.innerHTML = `<span class="t">${hhmm(it.created)}</span><span class="x"></span><span class="s">${STATUS[it.status] || it.status}${it.error ? ' · ' + it.error : ''}</span>`; l.innerHTML = `<span class="t"></span><span class="x"></span><span class="by"></span><span class="s"></span>`;
l.querySelector('.t').textContent = hhmm(it.created);
l.querySelector('.x').textContent = it.text; 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; return l;
} }
async function poll() { async function poll() {
try {
const s = await (await fetch('/api/status')).json();
const now = $('#now'); const now = $('#now');
try {
const s = await (await req('/api/status', { headers: headers(false) })).json();
if (s.current) { now.className = 'now'; now.innerHTML = '<span class="pulse"></span>'; now.append(document.createTextNode(s.current.text)); } if (s.current) { now.className = 'now'; now.innerHTML = '<span class="pulse"></span>'; now.append(document.createTextNode(s.current.text)); }
else { now.className = 'now idle'; now.textContent = ''; } else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; }
$('#queue').replaceChildren(...s.queued.map(li)); $('#queue').replaceChildren(...s.queued.map(li));
$('#history').replaceChildren(...s.history.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(); init();
</script> </script>

View file

@ -1,12 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Installerer SG Announcer på en Debian/Ubuntu-maskin (testet mot Linux Mint). # Installerer Announcer på en Debian/Ubuntu-maskin (testet mot Linux Mint).
# #
# git clone https://git.1x1.no/kent/sg-announcer.git # git clone https://git.1x1.no/kent/announcer.git
# cd sg-announcer && ./install.sh # cd announcer && ./install.sh
# #
# Kjør som vanlig bruker (ikke sudo); skriptet spør om passord der det trengs. # Kjør som vanlig bruker (ikke sudo); skriptet spør om passord der det trengs.
# Valgfritt: ANNOUNCER_PIN=1234 ./install.sh -> sida krever PIN # Valgfritt: ANNOUNCER_PIN=1234 ./install.sh -> sida krever PIN
# ANNOUNCER_PORT=8090 ./install.sh -> annen port # 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. # Kan kjøres på nytt; da oppdateres avhengigheter og tjenesten startes om.
set -euo pipefail set -euo pipefail
@ -15,11 +17,14 @@ if [[ $EUID -eq 0 ]]; then
exit 1 exit 1
fi fi
APP="announcer"
OLD_APP="sg-announcer" # het dette før; enheten ryddes bort nedenfor
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PORT="${ANNOUNCER_PORT:-8090}" PORT="${ANNOUNCER_PORT:-8090}"
PIN="${ANNOUNCER_PIN:-}" PIN="${ANNOUNCER_PIN:-}"
TITLE="${ANNOUNCER_TITLE:-}"
UNIT_DIR="$HOME/.config/systemd/user" UNIT_DIR="$HOME/.config/systemd/user"
UNIT="$UNIT_DIR/sg-announcer.service" UNIT="$UNIT_DIR/$APP.service"
step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
@ -44,17 +49,27 @@ echo "uv: $UV ($("$UV" --version))"
step "Python 3.13 og avhengigheter" step "Python 3.13 og avhengigheter"
cd "$REPO_DIR" cd "$REPO_DIR"
"$UV" sync "$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)" 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()" "$UV" run python -c "import logging, announcer; logging.basicConfig(level=logging.INFO, format='%(message)s'); announcer.ensure_voices()"
step "Rydder bort gammel tjeneste"
# Tjenesten het sg-announcer før. Uten dette slåss to enheter om porten.
if [[ -f "$UNIT_DIR/$OLD_APP.service" ]]; then
systemctl --user disable --now "$OLD_APP" >/dev/null 2>&1 || true
rm -f "$UNIT_DIR/$OLD_APP.service"
echo "fjernet $OLD_APP.service"
else
echo "ingenting å rydde."
fi
step "systemd-brukertjeneste" step "systemd-brukertjeneste"
mkdir -p "$UNIT_DIR" mkdir -p "$UNIT_DIR"
{ {
cat <<UNIT cat <<UNIT
[Unit] [Unit]
Description=SG Announcer – TTS-utrop på høyttaleranlegget Description=Announcer – TTS-utrop på høyttaleranlegget
After=pipewire.service pulseaudio.service After=pipewire.service pulseaudio.service
[Service] [Service]
@ -67,8 +82,16 @@ UNIT
else else
echo "# Environment=ANNOUNCER_PIN=1234" echo "# Environment=ANNOUNCER_PIN=1234"
fi fi
if [[ -n "$TITLE" ]]; then
echo "Environment=ANNOUNCER_TITLE=$TITLE"
else
echo "# Environment=ANNOUNCER_TITLE=Utrop"
fi
cat <<'UNIT' cat <<'UNIT'
# Environment=ANNOUNCER_SINK=alsa_output.xxx # 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 Restart=on-failure
RestartSec=3 RestartSec=3
@ -76,9 +99,10 @@ RestartSec=3
WantedBy=default.target WantedBy=default.target
UNIT UNIT
} > "$UNIT" } > "$UNIT"
chmod 600 "$UNIT" # PIN og nøkler står i klartekst her
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable --now sg-announcer systemctl --user enable --now "$APP"
systemctl --user restart sg-announcer systemctl --user restart "$APP"
step "Brannmur" step "Brannmur"
if ip link show wt0 >/dev/null 2>&1; then if ip link show wt0 >/dev/null 2>&1; then
@ -87,14 +111,14 @@ if ip link show wt0 >/dev/null 2>&1; then
sudo ufw default allow outgoing sudo ufw default allow outgoing
sudo ufw allow in on wt0 to any port 22 proto tcp comment "ssh via netbird" sudo ufw allow in on wt0 to any port 22 proto tcp comment "ssh via netbird"
sudo ufw allow in on wt0 to any port 22022 proto tcp comment "netbird ssh-server" sudo ufw allow in on wt0 to any port 22022 proto tcp comment "netbird ssh-server"
sudo ufw allow in on wt0 to any port "$PORT" proto tcp comment "sg-announcer via netbird" sudo ufw allow in on wt0 to any port "$PORT" proto tcp comment "$APP via netbird"
sudo ufw delete allow "$PORT/tcp" >/dev/null 2>&1 || true sudo ufw delete allow "$PORT/tcp" >/dev/null 2>&1 || true
sudo ufw --force enable sudo ufw --force enable
echo "ufw aktiv: port $PORT, 22 og 22022 er åpne kun på wt0 (NetBird)." echo "ufw aktiv: port $PORT, 22 og 22022 er åpne kun på wt0 (NetBird)."
echo "Vil du også nå sida fra lokalt LAN uten VPN:" echo "Vil du også nå sida fra lokalt LAN uten VPN:"
echo " sudo ufw allow from <lan-subnett> to any port $PORT proto tcp" echo " sudo ufw allow from <lan-subnett> to any port $PORT proto tcp"
elif sudo ufw status 2>/dev/null | grep -q "Status: active"; then elif sudo ufw status 2>/dev/null | grep -q "Status: active"; then
sudo ufw allow "$PORT/tcp" comment "sg-announcer" sudo ufw allow "$PORT/tcp" comment "$APP"
else else
echo "ufw er ikke aktiv, ingen regel nødvendig." echo "ufw er ikke aktiv, ingen regel nødvendig."
fi fi
@ -102,16 +126,20 @@ fi
sleep 2 sleep 2
IP="$(hostname -I 2>/dev/null | awk '{print $1}')" IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
step "Ferdig" step "Ferdig"
systemctl --user --no-pager status sg-announcer | head -5 || true systemctl --user --no-pager status "$APP" | head -5 || true
cat <<MSG cat <<MSG
Åpne http://${IP:-<denne-maskinens-ip>}:$PORT fra hva som helst på nettet. Åpne http://${IP:-<denne-maskinens-ip>}:$PORT fra hva som helst på nettet.
Uten config.toml er sida åpen for alle som når porten. Vil du ha innlogging:
cp config.example.toml config.toml og les kommentarene der.
Mot crew-panelet: docs/crew-integration.md
Tips for en laptop på arrangementet: Tips for en laptop på arrangementet:
- Lyden spilles i din innloggede økt, så maskinen må være logget inn. - Lyden spilles i din innloggede økt, så maskinen må være logget inn.
Slå på automatisk innlogging (Innstillinger -> Påloggingsvindu). Slå på automatisk innlogging (Innstillinger -> Påloggingsvindu).
- Hindre at den sover med lokket igjen: - Hindre at den sover med lokket igjen:
sudo sed -i 's/^#\?HandleLidSwitch=.*/HandleLidSwitch=ignore/' /etc/systemd/logind.conf sudo sed -i 's/^#\?HandleLidSwitch=.*/HandleLidSwitch=ignore/' /etc/systemd/logind.conf
sudo systemctl restart systemd-logind sudo systemctl restart systemd-logind
- Logg: journalctl --user -u sg-announcer -f - Logg: journalctl --user -u $APP -f
MSG MSG

View file

@ -1,17 +1,21 @@
[project] [project]
name = "sg-announcer" name = "announcer"
version = "0.1.0" version = "0.2.0"
description = "Add your description here" description = "Tekst inn i nettleseren, forhåndslytt, og les meldingen ut på høyttaleranlegget"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"edge-tts>=7.2.8", "edge-tts>=7.2.8",
"flask>=3.1.3", "flask>=3.1.3",
"piper-tts>=1.8.0", "piper-tts>=1.8.0",
"pyjwt[crypto]>=2.10",
] ]
[project.scripts] [dependency-groups]
sg-announcer = "sg_announcer:main" dev = [
"pytest>=8.3",
]
[build-system] [tool.uv]
requires = ["uv_build>=0.12.17,<0.13.0"] # Appen er noen løse .py-filer som kjøres på plass, ikke en pakke som skal
build-backend = "uv_build" # bygges og installeres. Da slipper vi også et build-backend.
package = false

View file

@ -1,2 +0,0 @@
def main() -> None:
print("Hello from sg-announcer!")

22
tests/conftest.py Normal file
View file

@ -0,0 +1,22 @@
"""Felles oppsett for testene.
Hele poenget her er å nulle ut miljøet. `config.load()` lar
ANNOUNCER_*-variabler overstyre konfigurasjonen, og det er riktig oppførsel –
men det betyr at testene ellers arver det maskina tilfeldigvis har satt.
På announcer-maskina setter systemd-enheten `ANNOUNCER_PORT`, og i
docker compose settes både JWT- og OIDC-hemmeligheter. Uten denne fila feiler
28 av testene der, med feilmeldinger som ikke peker på noe som er galt.
"""
from __future__ import annotations
import os
import pytest
@pytest.fixture(autouse=True)
def rent_miljø(monkeypatch):
"""Fjerner alle ANNOUNCER_*-variabler, så hver test bare ser sin egen config."""
for key in [k for k in os.environ if k.startswith("ANNOUNCER_")]:
monkeypatch.delenv(key, raising=False)

535
tests/test_auth.py Normal file
View file

@ -0,0 +1,535 @@
"""Tester for autentiseringen.
Auth er den ene delen av denne appen man ikke kan se etter med øyet: at en PIN
virker ser du med en gang, at et utløpt token blir avvist ser du aldri før det
smeller. Derfor står prøvene her og ikke rundt resten.
Testene kjører mot en bar Flask-app, ikke mot announcer.py – det ville dratt inn
piper, lastet stemmer og startet avspillingstråden.
"""
from __future__ import annotations
import sys
import time
import urllib.parse
from pathlib import Path
import jwt as pyjwt
import pytest
from flask import Flask, jsonify
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import auth # noqa: E402
import config # noqa: E402
SECRET = "en-delt-hemmelighet-mellom-panelet-og-announcer"
def build(tmp_path: Path, toml: str = "", **env: str):
"""Lager en liten app med gitt konfigurasjon. Returnerer testklienten."""
cfg_path = tmp_path / "config.toml"
cfg_path.write_text(f'[paths]\nstate = "{tmp_path}"\nlog = "{tmp_path}/a.jsonl"\n\n{toml}')
for key, value in env.items():
__import__("os").environ[key] = value
try:
cfg = config.load(cfg_path)
finally:
for key in env:
__import__("os").environ.pop(key, None)
app = Flask(__name__)
app.config["TESTING"] = True
authenticator = auth.install(app, cfg)
@app.get("/do/<action>")
def do(action: str): # noqa: ANN202
return jsonify(auth.require(action).public())
@app.errorhandler(401)
def _401(e): # noqa: ANN001, ANN202
return jsonify({"error": e.description}), 401
@app.errorhandler(403)
def _403(e): # noqa: ANN001, ANN202
return jsonify({"error": e.description}), 403
return app.test_client(), authenticator
def token(claims: dict, *, secret: str = SECRET, alg: str = "HS256", **over) -> str:
body = {"sub": "u1", "exp": time.time() + 600, **claims, **over}
return pyjwt.encode(body, secret, algorithm=alg)
# --------------------------------------------------------------------------
# Uten konfigurasjon skal ingenting være stengt
# --------------------------------------------------------------------------
def test_uten_konfig_er_alt_apent(tmp_path):
c, a = build(tmp_path)
assert a.enabled is False
for action in config.ACTIONS:
assert c.get(f"/do/{action}").status_code == 200, action
def test_tom_providers_liste_er_fortsatt_apen(tmp_path):
c, a = build(tmp_path, "[auth]\nproviders = []\n")
assert a.enabled is False
assert c.get("/do/clear").status_code == 200
# --------------------------------------------------------------------------
# PIN
# --------------------------------------------------------------------------
def test_pin_kreves_nar_den_er_satt(tmp_path):
c, a = build(tmp_path, '[auth.pin]\npin = "1234"\n')
assert a.enabled is True
assert [p.name for p in a.providers] == ["pin"]
assert c.get("/do/announce").status_code == 401
assert c.get("/do/announce", headers={"X-Pin": "feil"}).status_code == 401
assert c.get("/do/announce", headers={"X-Pin": "1234"}).status_code == 200
def test_pin_fra_miljovariabel_slar_pa_leverandoren(tmp_path):
"""Bakoverkompatibilitet: dette er alt systemd-enheten har satt fram til nå."""
c, a = build(tmp_path, ANNOUNCER_PIN="9999")
assert [p.name for p in a.providers] == ["pin"]
assert c.get("/do/clear", headers={"X-Pin": "9999"}).status_code == 200
assert c.get("/do/clear").status_code == 401
def test_feil_pin_gir_forstaelig_melding(tmp_path):
c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n')
assert c.get("/do/announce", headers={"X-Pin": "nope"}).get_json()["error"] == "Feil PIN"
def test_pin_gir_admin_som_standard(tmp_path):
c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n')
body = c.get("/do/clear", headers={"X-Pin": "1234"}).get_json()
assert body["roles"] == ["admin"]
assert sorted(body["can"]) == sorted(config.ACTIONS)
def test_pin_med_crew_rolle_far_ikke_tomme_koen(tmp_path):
c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\nrole = "crew"\n')
assert c.get("/do/announce", headers={"X-Pin": "1234"}).status_code == 200
r = c.get("/do/clear", headers={"X-Pin": "1234"})
assert r.status_code == 403
assert "crew" in r.get_json()["error"]
def test_pin_godtas_ogsa_i_authorization_header(tmp_path):
c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n')
assert c.get("/do/announce", headers={"Authorization": "Pin 1234"}).status_code == 200
# --------------------------------------------------------------------------
# Anonym rolle
# --------------------------------------------------------------------------
def test_anonym_kan_gis_leserett(tmp_path):
"""For statusskjermen som henger på veggen uten noen til å logge inn."""
c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n\n[roles]\nanonymous = ["view"]\n')
assert c.get("/do/view").status_code == 200
assert c.get("/do/announce").status_code == 401
# --------------------------------------------------------------------------
# JWT
# --------------------------------------------------------------------------
JWT_CFG = f'''
[auth.jwt]
secret = "{SECRET}"
audience = "announcer"
issuer = "https://crew.example.no"
'''
def test_gyldig_jwt_slipper_inn_med_rolle_og_navn(tmp_path):
c, a = build(tmp_path, JWT_CFG)
assert [p.name for p in a.providers] == ["jwt"]
t = token({"aud": "announcer", "iss": "https://crew.example.no",
"name": "Kari Nordmann", "roles": ["crew"], "sub": "42"})
body = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).get_json()
assert body["name"] == "Kari Nordmann"
assert body["roles"] == ["crew"]
assert body["subject"] == "jwt:42"
assert body["method"] == "jwt"
def test_utlopt_jwt_avvises(tmp_path):
c, _ = build(tmp_path, JWT_CFG)
t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]},
exp=time.time() - 3600)
r = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"})
assert r.status_code == 401
assert "utløpt" in r.get_json()["error"]
def test_jwt_for_en_annen_tjeneste_avvises(tmp_path):
c, _ = build(tmp_path, JWT_CFG)
t = token({"aud": "et-annet-system", "iss": "https://crew.example.no", "roles": ["admin"]})
r = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"})
assert r.status_code == 401
assert "annen tjeneste" in r.get_json()["error"]
def test_jwt_fra_feil_utsteder_avvises(tmp_path):
c, _ = build(tmp_path, JWT_CFG)
t = token({"aud": "announcer", "iss": "https://ondsinnet.example", "roles": ["admin"]})
assert c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).status_code == 401
def test_jwt_med_feil_signatur_avvises(tmp_path):
c, _ = build(tmp_path, JWT_CFG)
t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]},
secret="en-helt-annen-hemmelighet-som-er-lang-nok-for-hmac")
assert c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).status_code == 401
def test_jwt_uten_exp_avvises(tmp_path):
"""Et token uten utløpstid er en evig nøkkel; den vil vi ikke ha."""
c, _ = build(tmp_path, JWT_CFG)
body = {"sub": "u1", "aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]}
t = pyjwt.encode(body, SECRET, algorithm="HS256")
assert c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).status_code == 401
def test_jwt_rolle_uten_rettighet_gir_403_ikke_401(tmp_path):
"""Forskjellen betyr noe for klienten: 401 = logg inn, 403 = du får ikke."""
c, _ = build(tmp_path, JWT_CFG)
t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["crew"]})
h = {"Authorization": f"Bearer {t}"}
assert c.get("/do/announce", headers=h).status_code == 200
assert c.get("/do/clear", headers=h).status_code == 403
def test_ingen_authorization_header_gir_401(tmp_path):
c, _ = build(tmp_path, JWT_CFG)
assert c.get("/do/view").status_code == 401
@pytest.mark.parametrize(
"claims, role_claim, forventet",
[
({"roles": ["crew", "admin"]}, "roles", ["crew", "admin"]),
({"roles": "crew admin"}, "roles", ["crew", "admin"]),
({"roles": "crew,admin"}, "roles", ["crew", "admin"]),
({"roles": "crew"}, "roles", ["crew"]),
({"realm_access": {"roles": ["crew"]}}, "realm_access.roles", ["crew"]),
({"groups": ["admin"]}, "groups", ["admin"]),
({}, "roles", []),
],
)
def test_rolleclaim_tolkes_i_alle_formene(tmp_path, claims, role_claim, forventet):
c, _ = build(tmp_path, JWT_CFG + f'role_claim = "{role_claim}"\n')
t = token({"aud": "announcer", "iss": "https://crew.example.no", **claims})
r = c.get("/do/view", headers={"Authorization": f"Bearer {t}"})
if forventet:
assert r.get_json()["roles"] == forventet
else:
# Gyldig token uten roller: vi *vet* hvem dette er, han får bare ikke
# gjøre noe. Da er 403 riktig – å logge inn på nytt hjelper ikke.
assert r.status_code == 403
def test_default_role_brukes_nar_tokenet_mangler_roller(tmp_path):
c, _ = build(tmp_path, JWT_CFG + 'default_role = "crew"\n')
t = token({"aud": "announcer", "iss": "https://crew.example.no"})
body = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).get_json()
assert body["roles"] == ["crew"]
@pytest.mark.parametrize(
"claims, forventet",
[
({"name": "Kari"}, "Kari"),
({"preferred_username": "kari"}, "kari"),
({"email": "kari@example.no"}, "kari@example.no"),
({}, "u1"),
],
)
def test_visningsnavn_finnes_fram_fra_det_som_er(tmp_path, claims, forventet):
c, _ = build(tmp_path, JWT_CFG + 'default_role = "admin"\n')
t = token({"aud": "announcer", "iss": "https://crew.example.no", **claims})
assert c.get("/do/view", headers={"Authorization": f"Bearer {t}"}).get_json()["name"] == forventet
# --------------------------------------------------------------------------
# Token-lenka fra crew-panelet
# --------------------------------------------------------------------------
def test_token_lenke_gir_sesjon_sa_resten_virker_uten_header(tmp_path):
c, _ = build(tmp_path, JWT_CFG)
t = token({"aud": "announcer", "iss": "https://crew.example.no",
"name": "Kent", "roles": ["admin"]})
r = c.get(f"/auth/token?t={t}")
assert r.status_code == 302
body = c.get("/do/clear").get_json() # ingen header denne gangen
assert body["name"] == "Kent"
assert body["method"] == "jwt"
def test_token_lenke_med_ugyldig_token_gir_ingen_sesjon(tmp_path):
c, _ = build(tmp_path, JWT_CFG)
assert c.get("/auth/token?t=aldeles-ugyldig").status_code == 401
assert c.get("/do/view").status_code == 401
def test_token_lenke_finnes_ikke_uten_jwt_oppsett(tmp_path):
c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n')
assert c.get("/auth/token?t=hva-som-helst").status_code == 404
def test_utlogging_tommer_sesjonen(tmp_path):
c, _ = build(tmp_path, JWT_CFG)
t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]})
c.get(f"/auth/token?t={t}")
assert c.get("/do/clear").status_code == 200
c.get("/auth/logout")
assert c.get("/do/clear").status_code == 401
def test_rettigheter_slas_opp_pa_nytt_for_hver_forespørsel(tmp_path):
"""Endrer man [roles] skal det slå inn uten at folk må logge inn på nytt."""
c, a = build(tmp_path, JWT_CFG)
t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["crew"]})
c.get(f"/auth/token?t={t}")
assert c.get("/do/clear").status_code == 403
a.cfg.data["roles"]["crew"] = ["*"]
assert c.get("/do/clear").status_code == 200
# --------------------------------------------------------------------------
# Flere metoder samtidig
# --------------------------------------------------------------------------
def test_pin_og_jwt_kan_virke_side_om_side(tmp_path):
c, a = build(tmp_path, JWT_CFG + '\n[auth.pin]\npin = "1234"\nrole = "crew"\n')
assert sorted(p.name for p in a.providers) == ["jwt", "pin"]
assert c.get("/do/announce", headers={"X-Pin": "1234"}).status_code == 200
t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]})
assert c.get("/do/clear", headers={"Authorization": f"Bearer {t}"}).status_code == 200
def test_rekkefolgen_i_providers_bestemmer(tmp_path):
c, a = build(tmp_path, '[auth]\nproviders = ["jwt", "pin"]\n' + JWT_CFG + '\n[auth.pin]\npin = "1"\n')
assert [p.name for p in a.providers] == ["jwt", "pin"]
# --------------------------------------------------------------------------
# Enhetstester på småtingene
# --------------------------------------------------------------------------
@pytest.mark.parametrize(
"value, forventet",
[
(["a", "b"], ("a", "b")),
("a b", ("a", "b")),
("a,b", ("a", "b")),
("a", ("a",)),
(None, ()),
(42, ()),
([" a ", ""], ("a",)),
],
)
def test_roller_tolkes(value, forventet):
assert auth._roles(value) == forventet
def test_nestet_claim_hentes():
claims = {"realm_access": {"roles": ["crew"]}}
assert auth._claim(claims, "realm_access.roles") == ["crew"]
assert auth._claim(claims, "finnes.ikke") is None
assert auth._claim(claims, "realm_access.roles.dypere") is None
def test_rolle_til_handling():
cfg = config.load("/finnes-ikke.toml")
assert cfg.permissions(("admin",)) == frozenset(config.ACTIONS)
assert cfg.permissions(("crew",)) == frozenset({"view", "preview", "announce"})
assert cfg.permissions(("ukjent-rolle",)) == frozenset()
assert cfg.permissions(()) == frozenset()
# Roller legges sammen
cfg.data["roles"]["bare_stopp"] = ["stop"]
assert cfg.permissions(("crew", "bare_stopp")) == frozenset({"view", "preview", "announce", "stop"})
def test_ukjente_handlinger_i_roles_ignoreres():
cfg = config.load("/finnes-ikke.toml")
cfg.data["roles"]["rar"] = ["announce", "slett-alt"]
assert cfg.permissions(("rar",)) == frozenset({"announce"})
def test_sesjonsnokkel_er_stabil_mellom_omstarter(tmp_path):
cfg = config.load("/finnes-ikke.toml")
cfg.data["paths"]["state"] = str(tmp_path)
first = auth.secret_key(cfg)
assert auth.secret_key(cfg) == first
assert (tmp_path / "secret_key").stat().st_mode & 0o777 == 0o600
# --------------------------------------------------------------------------
# OIDC
# --------------------------------------------------------------------------
# Crew-panelet har ikke OIDC-endepunkter ennå, så her står en stubbet
# identitetsleverandør i stedet. Det er HTTP-kallene som stubbes; discovery,
# PKCE, state-sjekken, kodebyttet og verifiseringen av id_token er vår egen
# kode og kjøres på ordentlig.
OIDC_CFG = '''
[auth.oidc]
issuer = "https://crew.example.no"
client_id = "announcer"
client_secret = "panel-hemmelighet-som-er-lang-nok-for-hmac-sha256"
'''
DISCOVERY = {
"issuer": "https://crew.example.no",
"authorization_endpoint": "https://crew.example.no/oauth/authorize",
"token_endpoint": "https://crew.example.no/oauth/token",
"jwks_uri": "",
}
@pytest.fixture
def idp(monkeypatch):
"""Stubber HTTP-laget og husker hva vi sendte til tokenendepunktet."""
state: dict = {"posts": [], "claims": {}}
def fake_get(url, timeout=10):
assert url == "https://crew.example.no/.well-known/openid-configuration"
return dict(DISCOVERY)
def fake_post(url, form, headers=None, timeout=10):
state["posts"].append({"url": url, "form": form, "headers": headers or {}})
claims = {
"iss": "https://crew.example.no",
"aud": "announcer",
"exp": time.time() + 600,
"sub": "142",
**state["claims"],
}
# Panelet signerer id_token med client_secret (HS256) i dette oppsettet.
return {"id_token": pyjwt.encode(claims, "panel-hemmelighet-som-er-lang-nok-for-hmac-sha256", algorithm="HS256")}
monkeypatch.setattr(auth, "_get_json", fake_get)
monkeypatch.setattr(auth, "_post_json", fake_post)
return state
def test_oidc_login_sender_brukeren_til_panelet_med_pkce(tmp_path, idp):
c, _ = build(tmp_path, OIDC_CFG)
r = c.get("/auth/login")
assert r.status_code == 302
url = urllib.parse.urlparse(r.headers["Location"])
q = urllib.parse.parse_qs(url.query)
assert url.netloc == "crew.example.no" and url.path == "/oauth/authorize"
assert q["response_type"] == ["code"]
assert q["client_id"] == ["announcer"]
assert q["code_challenge_method"] == ["S256"]
assert q["redirect_uri"][0].endswith("/auth/callback")
assert len(q["code_challenge"][0]) == 43 # sha256, base64url uten padding
assert q["state"][0]
def test_oidc_hele_runden_gir_innlogget_sesjon(tmp_path, idp):
idp["claims"] = {"name": "Kari Nordmann", "roles": ["crew"]}
c, _ = build(tmp_path, OIDC_CFG)
q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query)
r = c.get(f"/auth/callback?code=en-kode&state={q['state'][0]}")
assert r.status_code == 302 and r.headers["Location"] == "/"
body = c.get("/do/announce").get_json()
assert body["name"] == "Kari Nordmann"
assert body["roles"] == ["crew"]
assert body["subject"] == "oidc:142"
assert body["method"] == "oidc"
assert c.get("/do/clear").status_code == 403
# Kodebyttet: PKCE-verifier med, hemmeligheten som Basic-auth, ikke i body.
post = idp["posts"][0]
assert post["url"] == "https://crew.example.no/oauth/token"
assert post["form"]["grant_type"] == "authorization_code"
assert post["form"]["code"] == "en-kode"
assert len(post["form"]["code_verifier"]) > 40
assert "client_secret" not in post["form"]
assert post["headers"]["Authorization"].startswith("Basic ")
def test_oidc_callback_med_feil_state_avvises(tmp_path, idp):
"""Uten denne sjekken kan en angriper få deg til å logge inn som seg selv."""
c, _ = build(tmp_path, OIDC_CFG)
c.get("/auth/login")
r = c.get("/auth/callback?code=en-kode&state=tilfeldig-tull")
assert r.status_code == 400
assert c.get("/do/view").status_code == 401
def test_oidc_callback_uten_forutgaende_login_avvises(tmp_path, idp):
c, _ = build(tmp_path, OIDC_CFG)
assert c.get("/auth/callback?code=x&state=y").status_code == 400
def test_oidc_feil_fra_panelet_vises(tmp_path, idp):
c, _ = build(tmp_path, OIDC_CFG)
c.get("/auth/login")
r = c.get("/auth/callback?error=access_denied&error_description=Avvist+av+bruker")
assert r.status_code == 401
assert "access_denied" in r.get_json()["error"]
def test_oidc_id_token_for_en_annen_klient_avvises(tmp_path, idp, monkeypatch):
c, _ = build(tmp_path, OIDC_CFG)
monkeypatch.setattr(auth, "_post_json", lambda url, form, headers=None, timeout=10: {
"id_token": pyjwt.encode(
{"iss": "https://crew.example.no", "aud": "et-annet-system",
"exp": time.time() + 600, "sub": "1"},
"panel-hemmelighet-som-er-lang-nok-for-hmac-sha256", algorithm="HS256")})
q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query)
assert c.get(f"/auth/callback?code=k&state={q['state'][0]}").status_code == 401
def test_oidc_state_kan_ikke_brukes_to_ganger(tmp_path, idp):
c, _ = build(tmp_path, OIDC_CFG)
q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query)
state = q["state"][0]
assert c.get(f"/auth/callback?code=k&state={state}").status_code == 302
c.get("/auth/logout")
assert c.get(f"/auth/callback?code=k&state={state}").status_code == 400
def test_oidc_manuelle_endepunkter_hopper_over_discovery(tmp_path, monkeypatch):
"""Panelet har ikke .well-known ennå, så dette er veien dit først."""
def nei(*a, **kw):
raise AssertionError("skulle ikke slått opp discovery")
monkeypatch.setattr(auth, "_get_json", nei)
c, _ = build(tmp_path, OIDC_CFG + '''
authorization_endpoint = "https://crew.example.no/oauth/authorize"
token_endpoint = "https://crew.example.no/oauth/token"
''')
assert c.get("/auth/login").status_code == 302
def test_oidc_login_finnes_ikke_uten_oppsett(tmp_path):
c, _ = build(tmp_path, '[auth.pin]\npin = "1"\n')
assert c.get("/auth/login").status_code == 404
assert c.get("/auth/callback?code=x&state=y").status_code == 404
def test_public_url_styrer_redirect_uri(tmp_path, idp):
"""Bak en omvendt proxy må redirect_uri peke utover, ikke på localhost."""
c, _ = build(tmp_path, '[server]\npublic_url = "https://utrop.example.no"\n' + OIDC_CFG)
q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query)
assert q["redirect_uri"] == ["https://utrop.example.no/auth/callback"]

245
uv.lock generated
View file

@ -146,6 +146,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
] ]
[[package]]
name = "announcer"
version = "0.2.0"
source = { virtual = "." }
dependencies = [
{ name = "edge-tts" },
{ name = "flask" },
{ name = "piper-tts" },
{ name = "pyjwt", extra = ["crypto"] },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "edge-tts", specifier = ">=7.2.8" },
{ name = "flask", specifier = ">=3.1.3" },
{ name = "piper-tts", specifier = ">=1.8.0" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.10" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.3" }]
[[package]] [[package]]
name = "attrs" name = "attrs"
version = "26.1.0" version = "26.1.0"
@ -173,6 +200,79 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, { 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]] [[package]]
name = "click" name = "click"
version = "8.5.0" version = "8.5.0"
@ -182,6 +282,78 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, { 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]] [[package]]
name = "edge-tts" name = "edge-tts"
version = "7.2.8" version = "7.2.8"
@ -304,6 +476,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, { 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]] [[package]]
name = "itsdangerous" name = "itsdangerous"
version = "2.2.0" version = "2.2.0"
@ -665,6 +846,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/9c/c736d1961cf9ce0655278731b9430df41892ca2ded7b09dabcb340612158/piper_tts-1.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:5da9bfdb05dfe15da3536859d422e605483ffa6d2b3ec2c5b9593bae6b5aa6a4", size = 34119688, upload-time = "2026-09-04T16:47:28.339Z" }, { 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]] [[package]]
name = "propcache" name = "propcache"
version = "0.5.4" version = "0.5.4"
@ -775,20 +965,51 @@ wheels = [
] ]
[[package]] [[package]]
name = "sg-announcer" name = "pycparser"
version = "0.1.0" version = "3.1"
source = { editable = "." } source = { registry = "https://pypi.org/simple" }
dependencies = [ 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" }
{ name = "edge-tts" }, wheels = [
{ name = "flask" }, { 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" },
{ name = "piper-tts" },
] ]
[package.metadata] [[package]]
requires-dist = [ name = "pygments"
{ name = "edge-tts", specifier = ">=7.2.8" }, version = "2.21.0"
{ name = "flask", specifier = ">=3.1.3" }, source = { registry = "https://pypi.org/simple" }
{ name = "piper-tts", specifier = ">=1.8.0" }, 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]] [[package]]