feat(podcast): fælles familielink uden login
CD / deploy (push) Successful in 15s
CI / check (push) Successful in 23s

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
This commit is contained in:
agw
2026-10-04 20:11:28 +02:00
parent 4274047158
commit 94e5a0ca01
5 changed files with 208 additions and 28 deletions
+70 -2
View File
@@ -11,6 +11,7 @@ Data (ikke i git, persondata):
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.
@@ -29,6 +30,7 @@ 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}$")
@@ -213,11 +215,12 @@ def _hms(sek: int) -> str:
return f"{sek // 3600:d}:{sek // 60 % 60:02d}:{sek % 60:02d}"
def feed(name: str, key: str, base: str, med_kladder: bool) -> str | None:
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}/podcast/{key}/{name}"
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"])
@@ -254,3 +257,68 @@ def feed(name: str, key: str, base: str, med_kladder: bool) -> str | None:
</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)