Compare commits
4 commits
3f37b0a7fb
...
6aaa98d8b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aaa98d8b1 | |||
| d6eb811ee5 | |||
| 87066bb8c2 | |||
| f191fb2b90 |
18 changed files with 2831 additions and 149 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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
177
AGENTS.md
|
|
@ -1,39 +1,165 @@
|
||||||
# sg-announcer – utrop på høyttaleranlegget (Sauda Gathering)
|
# announcer – utrop på høyttaleranlegget
|
||||||
|
|
||||||
Liten Flask-app: tekst inn i nettleseren → forhåndslytt → «Send ut på
|
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
31
Dockerfile
Normal 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"]
|
||||||
185
announcer.py
185
announcer.py
|
|
@ -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
642
auth.py
Normal 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
45
compose.yaml
Normal 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
123
config.example.toml
Normal 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
275
config.py
Normal 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
39
docker/config.test.toml
Normal 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
144
docker/dummy_idp.py
Normal 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
204
docs/crew-integration.md
Normal 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.
|
||||||
232
index.html
232
index.html
|
|
@ -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>
|
||||||
|
|
|
||||||
52
install.sh
52
install.sh
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
def main() -> None:
|
|
||||||
print("Hello from sg-announcer!")
|
|
||||||
22
tests/conftest.py
Normal file
22
tests/conftest.py
Normal 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
535
tests/test_auth.py
Normal 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
245
uv.lock
generated
|
|
@ -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]]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue