Files
weircon-front/podcast.py
T
agw 94e5a0ca01
CD / deploy (push) Successful in 15s
CI / check (push) Successful in 23s
feat(podcast): fælles familielink uden login
Et valgfrit fælles link, /podcast/f/<nøgle>/, til fx familiens Messenger-
gruppe: samme afspillerside som den personlige, med abonnér-knapper og feed
under samme sti, men uden login og uden admin-ting. Kladder vises aldrig der.
I /admin/podcast laves linket, nøglen kan skiftes (det gamle dør med det
samme), det kan slås fra, og hentningerne tælles.

Siden er noindex (header og meta) og sender ingen referrer. Forkerte nøgler
giver 404 ligesom ukendte stier, og en klient der rammer 20 forkerte nøgler
på ti minutter, får 404 på alt under /podcast/<nøgle> i et stykke tid.
Det gælder også de personlige nøgler.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-04 20:11:28 +02:00

325 lines
11 KiB
Python

"""Private podcasts (Slægtens Stemmer): side, personlige feeds og lydfiler.
Podcast-apps kan ikke logge ind med Authentik, så hver lytter får en personlig,
hemmelig nøgle i adressen, som Patreon og Apple gør det. Nøglen hører til
Authentik-brugeren og kan trækkes tilbage i /admin/podcast uden at de andre
mærker noget. Både feedet og mp3-filerne ligger under nøglen.
Data (ikke i git, persondata):
DATA/podcast/<show>/show.json titel, beskrivelse, forfatter
DATA/podcast/<show>/cover.jpg kvadratisk, 1400-3000 px
DATA/podcast/<show>/episodes/<slug>.mp3 selve episoden
DATA/podcast/<show>/episodes/<slug>.json titel, beskrivelse, nr, udgivet, kladde
DATA/podcast_keys.json nøgle -> bruger, statistik
DATA/podcast_faelles.json det fælles familielink (én nøgle, kan slås fra)
En episode med "kladde": true ses kun af administratorer (i deres eget feed og
på siden), så den kan høres i en rigtig podcast-app før familien får den.
"""
import email.utils
import functools
import json
import os
import re
import secrets
import time
from pathlib import Path
from xml.sax.saxutils import escape, quoteattr
import config
ROOT = config.DATA / "podcast"
KEYS = config.DATA / "podcast_keys.json"
DELT = config.DATA / "podcast_faelles.json"
SHOW = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
SLUG = re.compile(r"^[a-z0-9][a-z0-9._-]{0,120}$")
KEY = re.compile(r"^[A-Za-z0-9_-]{32,64}$")
# ---------------- shows og episoder ----------------
def shows() -> list[str]:
if not ROOT.exists():
return []
return sorted(p.name for p in ROOT.iterdir() if p.is_dir() and SHOW.match(p.name) and (p / "show.json").exists())
def show(name: str) -> dict | None:
if not SHOW.match(name or ""):
return None
try:
s = json.loads((ROOT / name / "show.json").read_text())
except (OSError, ValueError):
return None
s["slug"] = name
s["har_cover"] = (ROOT / name / "cover.jpg").exists()
return s
@functools.lru_cache(maxsize=256)
def _varighed(sti: str, mtime: float) -> int:
try:
from mutagen.mp3 import MP3
return int(MP3(sti).info.length)
except Exception:
return 0
def episodes(name: str, med_kladder: bool) -> list[dict]:
"""Episoder, nyeste først. Kladder kun med med_kladder."""
d = ROOT / name / "episodes"
if not SHOW.match(name or "") or not d.exists():
return []
ud = []
for meta in d.glob("*.json"):
slug = meta.stem
mp3 = d / f"{slug}.mp3"
if not SLUG.match(slug) or not mp3.exists():
continue
try:
e = json.loads(meta.read_text())
except ValueError:
continue
if e.get("kladde") and not med_kladder:
continue
st = mp3.stat()
e.update(slug=slug, bytes=st.st_size, varighed=_varighed(str(mp3), st.st_mtime))
ud.append(e)
return sorted(ud, key=lambda e: (e.get("nr", 0), e.get("udgivet", "")), reverse=True)
def episode_fil(name: str, slug: str) -> Path | None:
if not SHOW.match(name or "") or not SLUG.match(slug or ""):
return None
p = ROOT / name / "episodes" / f"{slug}.mp3"
return p if p.exists() else None
def episode_meta(name: str, slug: str) -> dict | None:
if not episode_fil(name, slug):
return None
try:
return json.loads((ROOT / name / "episodes" / f"{slug}.json").read_text())
except (OSError, ValueError):
return None
def saet_kladde(name: str, slug: str, kladde: bool) -> bool:
e = episode_meta(name, slug)
if e is None:
return False
e["kladde"] = kladde
if not kladde and not e.get("udgivet"):
e["udgivet"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
_skriv(ROOT / name / "episodes" / f"{slug}.json", e)
return True
def cover(name: str) -> Path | None:
p = ROOT / name / "cover.jpg"
return p if SHOW.match(name or "") and p.exists() else None
# ---------------- nøgler ----------------
def _skriv(p: Path, data) -> None:
tmp = p.with_name(f".{p.name}.tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(data, f, ensure_ascii=False, indent=1)
os.replace(tmp, p)
def _noegler() -> dict:
try:
return json.loads(KEYS.read_text())
except (OSError, ValueError):
return {}
def noegle_til(user: str, admin: bool) -> str:
"""Brugerens aktive nøgle; laves første gang. Admin-status opdateres her,
fordi den afgør om feedet viser kladder."""
k = _noegler()
for key, v in k.items():
if v["user"] == user and not v.get("tilbagekaldt"):
if v.get("admin") != admin:
v["admin"] = admin
_skriv(KEYS, k)
return key
key = secrets.token_urlsafe(32)
k[key] = {"user": user, "admin": admin, "oprettet": int(time.time()), "hentninger": 0}
_skriv(KEYS, k)
return key
def ny_noegle(user: str, admin: bool) -> str:
k = _noegler()
for v in k.values():
if v["user"] == user and not v.get("tilbagekaldt"):
v["tilbagekaldt"] = int(time.time())
_skriv(KEYS, k)
return noegle_til(user, admin)
def slaa_op(key: str, ua: str = "", hvad: str = "") -> dict | None:
"""Gyldig nøgle -> dens post (og registrér hentningen), ellers None."""
if not KEY.match(key or ""):
return None
k = _noegler()
v = k.get(key)
if not v or v.get("tilbagekaldt"):
return None
v["hentninger"] = v.get("hentninger", 0) + 1
v["sidst"] = int(time.time())
v["sidst_ua"] = (ua or "")[:160]
if hvad:
v.setdefault("hentet", {})
v["hentet"][hvad] = v["hentet"].get(hvad, 0) + 1
_skriv(KEYS, k)
return v
def alle_noegler() -> list[dict]:
return sorted(({"key": key, **v} for key, v in _noegler().items()),
key=lambda v: (bool(v.get("tilbagekaldt")), -(v.get("sidst") or 0)))
def tilbagekald(key: str, aktiv: bool) -> bool:
k = _noegler()
if key not in k:
return False
if aktiv:
# Genaktivering: brugeren må kun have én aktiv nøgle.
for other, v in k.items():
if other != key and v["user"] == k[key]["user"] and not v.get("tilbagekaldt"):
v["tilbagekaldt"] = int(time.time())
k[key].pop("tilbagekaldt", None)
else:
k[key]["tilbagekaldt"] = int(time.time())
_skriv(KEYS, k)
return True
# ---------------- feed ----------------
def _rfc822(iso: str) -> str:
try:
t = time.mktime(time.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S"))
except (ValueError, TypeError):
t = time.time()
return email.utils.formatdate(t, localtime=True)
def _hms(sek: int) -> str:
return f"{sek // 3600:d}:{sek // 60 % 60:02d}:{sek % 60:02d}"
def feed(name: str, rod: str, base: str, med_kladder: bool) -> str | None:
"""rod = stien foran showet, fx "/podcast/<nøgle>" eller "/podcast/f/<fælles nøgle>"."""
s = show(name)
if not s:
return None
pre = f"{base}{rod}/{name}"
items = []
for e in episodes(name, med_kladder):
titel = ("[Kladde] " if e.get("kladde") else "") + e.get("titel", e["slug"])
items.append(f""" <item>
<title>{escape(titel)}</title>
<description>{escape(e.get("beskrivelse", ""))}</description>
<guid isPermaLink="false">{escape(name)}/{escape(e["slug"])}</guid>
<pubDate>{_rfc822(e.get("udgivet", ""))}</pubDate>
<enclosure url={quoteattr(f"{pre}/{e['slug']}.mp3")} length="{e['bytes']}" type="audio/mpeg"/>
<itunes:duration>{_hms(e["varighed"])}</itunes:duration>
<itunes:episode>{int(e.get("nr", 0))}</itunes:episode>
<itunes:episodeType>full</itunes:episodeType>
</item>""")
cover_url = f"{pre}/cover.jpg"
return f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{escape(s.get("titel", name))}</title>
<link>{escape(base)}/podcast</link>
<atom:link href={quoteattr(f"{pre}.xml")} rel="self" type="application/rss+xml"/>
<language>{escape(s.get("sprog", "da"))}</language>
<description>{escape(s.get("beskrivelse", ""))}</description>
<itunes:summary>{escape(s.get("beskrivelse", ""))}</itunes:summary>
<itunes:author>{escape(s.get("forfatter", ""))}</itunes:author>
<itunes:image href={quoteattr(cover_url)}/>
<image><url>{escape(cover_url)}</url><title>{escape(s.get("titel", name))}</title><link>{escape(base)}/podcast</link></image>
<itunes:category text="History"/>
<itunes:explicit>false</itunes:explicit>
<itunes:type>serial</itunes:type>
<itunes:block>Yes</itunes:block>
<podcast:locked>yes</podcast:locked>
{chr(10).join(items)}
</channel>
</rss>
"""
# ---------------- fælles familielink ----------------
# Ét link uden login til fx familiens Messenger-gruppe: /podcast/f/<nøgle>/ med
# afspiller og feed. Viser aldrig kladder. Kan skiftes (det gamle dør med det
# samme) eller slås helt fra i /admin/podcast.
def faelles() -> dict:
try:
return json.loads(DELT.read_text())
except (OSError, ValueError):
return {}
def faelles_skift() -> str:
d = faelles()
d.update(key=secrets.token_urlsafe(32), aktiv=True, oprettet=int(time.time()), hentninger=0, hentet={})
d.pop("sidst", None)
_skriv(DELT, d)
return d["key"]
def faelles_aktiv(aktiv: bool) -> None:
d = faelles()
if not d.get("key"):
faelles_skift()
d = faelles()
d["aktiv"] = aktiv
_skriv(DELT, d)
def faelles_slaa_op(key: str, hvad: str = "") -> bool:
d = faelles()
if not (KEY.match(key or "") and d.get("aktiv") and d.get("key")
and secrets.compare_digest(key, d["key"])):
return False
if hvad:
d["hentninger"] = d.get("hentninger", 0) + 1
d["sidst"] = int(time.time())
d.setdefault("hentet", {})
d["hentet"][hvad] = d["hentet"].get(hvad, 0) + 1
_skriv(DELT, d)
return True
# ---------------- gæt på nøgler ----------------
# Nøglerne er 256 bit og kan ikke gættes, men en klient der bliver ved med at
# ramme forkerte nøgler, får 404 uden at vi overhovedet slår op (og samme svar
# som en ukendt sti, så den ikke røber noget).
_MISS: dict[str, list[float]] = {}
MISS_GRAENSE, MISS_VINDUE = 20, 600
def blokeret(ip: str) -> bool:
nu = time.time()
t = [x for x in _MISS.get(ip, []) if nu - x < MISS_VINDUE]
_MISS[ip] = t
return len(t) >= MISS_GRAENSE
def forkert(ip: str) -> None:
_MISS.setdefault(ip, []).append(time.time())
if len(_MISS) > 5000: # hold hukommelsen nede ved en storm
for k in list(_MISS)[:2500]:
_MISS.pop(k, None)