From f191fb2b902ce3325581a0eaf666b4f9b1305894 Mon Sep 17 00:00:00 2001 From: Kent Date: Thu, 8 Oct 2026 15:42:36 +0200 Subject: [PATCH 1/4] =?UTF-8?q?D=C3=B8p=20om=20sg-announcer=20til=20announ?= =?UTF-8?q?cer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appen var navngitt etter Sauda Gathering, men det er ingenting arrangementsspesifikt i selve koden. Navnet står igjen i tjenesten, install.sh og pyproject. - install.sh skriver nå announcer.service, og rydder bort en gammel sg-announcer.service først. Uten det slåss to enheter om port 8090 når skriptet kjøres på en maskin som har den gamle. - Enhetsfila får chmod 600; PIN-en står i klartekst i den. - src/sg_announcer/ er slettet. Det var en uv-init-stub som bare printet «Hello from sg-announcer!», pekt på av [project.scripts], og som ingen har kalt noen gang. - [tool.uv] package = false: appen er løse .py-filer som kjøres på plass, ikke en pakke. Uten den vil uv sync bygge et wheel og klage på at announcer/__init__.py mangler. Repoet i Forgejo og mappa er døpt om tilsvarende. Co-Authored-By: Claude Opus 5 (1M context) --- announcer.py | 4 ++-- index.html | 4 ++-- install.sh | 35 ++++++++++++++++++++++++----------- pyproject.toml | 14 ++++++-------- src/sg_announcer/__init__.py | 2 -- uv.lock | 34 +++++++++++++++++----------------- 6 files changed, 51 insertions(+), 42 deletions(-) delete mode 100644 src/sg_announcer/__init__.py diff --git a/announcer.py b/announcer.py index 46fe322..6a98af8 100644 --- a/announcer.py +++ b/announcer.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""SG Announcer – tekst inn, forhåndslytt i nettleseren, les ut på PC-ens lydutgang. +"""Announcer – tekst inn, forhåndslytt i nettleseren, les ut på PC-ens lydutgang. Kjøres på PC-en som er koblet til høyttaleranlegget. Alle på nettet kan åpne sida og sende meldinger; selve avspillingen skjer alltid lokalt på denne -maskinen via pw-play (PipeWire). +maskinen via pw-play (PipeWire) eller paplay. """ from __future__ import annotations diff --git a/index.html b/index.html index af2cb03..c6ca57e 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ -SG Announcer +Announcer
-

Announcer

-

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

+
+
+

+

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

    Dummy-IdP

    +

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

    +
      {rows}
    +

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

    """ + + code = secrets.token_urlsafe(24) + codes[code] = { + "user": who, + "challenge": request.args["code_challenge"], + "redirect_uri": request.args["redirect_uri"], + "exp": time.time() + 300, + } + sep = "&" if "?" in request.args["redirect_uri"] else "?" + params = urllib.parse.urlencode({"code": code, "state": request.args["state"]}) + return redirect(f"{request.args['redirect_uri']}{sep}{params}") + + +@app.post("/oauth/token") +def token(): + code = request.form.get("code", "") + entry = codes.pop(code, None) # engangsbruk + if not entry or entry["exp"] < time.time(): + return jsonify({"error": "invalid_grant"}), 400 + if request.form.get("redirect_uri") != entry["redirect_uri"]: + return jsonify({"error": "invalid_grant", "error_description": "redirect_uri stemmer ikke"}), 400 + + # PKCE: verifier skal hashe til challenge vi fikk i authorize. + verifier = request.form.get("code_verifier", "") + expected = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode()).digest() + ).rstrip(b"=").decode() + if not verifier or expected != entry["challenge"]: + return jsonify({"error": "invalid_grant", "error_description": "PKCE stemmer ikke"}), 400 + + user = USERS[entry["user"]] + claims = { + "iss": ISSUER, + "aud": CLIENT_ID, + "iat": int(time.time()), + "exp": int(time.time()) + 3600, + **user, + } + return jsonify({ + "access_token": secrets.token_urlsafe(16), + "token_type": "Bearer", + "expires_in": 3600, + "id_token": jwt.encode(claims, CLIENT_SECRET, algorithm="HS256"), + }) + + +@app.get("/token") +def mint(): + """Lager et JWT til token-lenka og til curl, slik panelet skal gjøre. + + http://localhost:9000/token?who=kari -> rått token + http://localhost:9000/token?who=kari&go=1 -> logger deg rett inn + """ + user = USERS.get(request.args.get("who", "kari")) + if not user: + abort(404, "ukjent bruker") + raw = jwt.encode( + {"iss": ISSUER, "aud": CLIENT_ID, "exp": int(time.time()) + 300, **user}, + os.environ.get("ANNOUNCER_JWT_SECRET", "test-hemmelighet-kun-for-docker-ikke-bruk-ekte"), + algorithm="HS256", + ) + if request.args.get("go"): + base = os.environ.get("ANNOUNCER_PUBLIC_URL", "http://localhost:8091") + return redirect(f"{base}/auth/token?t={raw}") + return raw, 200, {"Content-Type": "text/plain"} + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=9000, threaded=True)