diff --git a/main.py b/main.py index 17203bb..1777a14 100644 --- a/main.py +++ b/main.py @@ -398,23 +398,37 @@ async def admin_secrets_delete(request: Request, navn: str): # ---------------- podcast (Slægtens Stemmer) ---------------- +def _klient_ip(request: Request) -> str: + return request.headers.get("x-real-ip") or (request.client.host if request.client else "?") + + +def _podcast_kontekst(request: Request, rod: str, med_kladder: bool) -> list[dict]: + """Shows til podcast.html; rod = "/podcast/" eller "/podcast/f/".""" + base = str(request.base_url).rstrip("/") + ud = [] + for name in podcast.shows(): + s = podcast.show(name) + feed = f"{base}{rod}/{name}.xml" + ud.append({**s, "feed": feed, "uden_skema": feed.split("://", 1)[1], + "feed_enc": urllib.parse.quote(feed, safe=""), + "episoder": podcast.episodes(name, med_kladder=med_kladder)}) + return ud + + +def _ikke_fundet(request: Request): + podcast.forkert(_klient_ip(request)) + raise HTTPException(404) + + @app.get("/podcast", response_class=HTMLResponse) async def podcast_side(request: Request, saved: str = ""): user = require_familie(request) admin = is_admin(user) key = podcast.noegle_til(user["username"], admin) - base = str(request.base_url).rstrip("/") - udsendelser = [] - for name in podcast.shows(): - s = podcast.show(name) - feed = f"{base}/podcast/{key}/{name}.xml" - uden = feed.split("://", 1)[1] - udsendelser.append({**s, "feed": feed, "uden_skema": uden, - "feed_enc": urllib.parse.quote(feed, safe=""), - "episoder": podcast.episodes(name, med_kladder=admin)}) + rod = f"/podcast/{key}" return templates.TemplateResponse(request, "podcast.html", { - "request": request, "user": user, "is_admin": admin, "key": key, - "udsendelser": udsendelser, "saved": saved}) + "request": request, "user": user, "is_admin": admin, "rod": rod, "delt": False, + "udsendelser": _podcast_kontekst(request, rod, med_kladder=admin), "saved": saved}) @app.post("/podcast/ny-noegle") @@ -425,12 +439,60 @@ async def podcast_ny_noegle(request: Request): status_code=303) +# Det fælles familielink: ingen login, ingen kladder, noindex. Står før de +# personlige ruter, så "f" aldrig forveksles med en nøgle. +@app.get("/podcast/f/{key}/", response_class=HTMLResponse) +async def podcast_faelles_side(request: Request, key: str): + if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key, "side"): + _ikke_fundet(request) + rod = f"/podcast/f/{key}" + return templates.TemplateResponse(request, "podcast.html", { + "request": request, "user": None, "is_admin": False, "rod": rod, "delt": True, + "udsendelser": _podcast_kontekst(request, rod, med_kladder=False), "saved": ""}, + headers={"X-Robots-Tag": "noindex, nofollow, noarchive", "Referrer-Policy": "no-referrer", + "Cache-Control": "private, no-store"}) + + +@app.get("/podcast/f/{key}/{name}.xml") +async def podcast_faelles_feed(request: Request, key: str, name: str): + if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key, "feed"): + _ikke_fundet(request) + xml = podcast.feed(name, f"/podcast/f/{key}", str(request.base_url).rstrip("/"), med_kladder=False) + if xml is None: + raise HTTPException(404) + return Response(xml, media_type="application/rss+xml; charset=utf-8", + headers={"Cache-Control": "private, max-age=300", "X-Robots-Tag": "noindex, nofollow"}) + + +@app.get("/podcast/f/{key}/{name}/cover.jpg") +async def podcast_faelles_cover(request: Request, key: str, name: str): + p = podcast.cover(name) + if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key) or not p: + _ikke_fundet(request) + return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"}) + + +@app.get("/podcast/f/{key}/{name}/{slug}.mp3") +async def podcast_faelles_mp3(request: Request, key: str, name: str, slug: str): + rng = request.headers.get("range", "") + fra_start = not rng or rng.replace(" ", "").startswith("bytes=0-") + p = podcast.episode_fil(name, slug) + meta = podcast.episode_meta(name, slug) or {} + if (podcast.blokeret(_klient_ip(request)) or not p or meta.get("kladde") + or not podcast.faelles_slaa_op(key, slug if fra_start else "")): + _ikke_fundet(request) + return FileResponse(p, media_type="audio/mpeg", + headers={"Cache-Control": "private, max-age=86400", "X-Robots-Tag": "noindex, nofollow"}) + + @app.get("/podcast/{key}/{name}.xml") async def podcast_feed(request: Request, key: str, name: str): + if podcast.blokeret(_klient_ip(request)): + _ikke_fundet(request) v = podcast.slaa_op(key, request.headers.get("user-agent", ""), "feed") if not v: - raise HTTPException(404) - xml = podcast.feed(name, key, str(request.base_url).rstrip("/"), med_kladder=bool(v.get("admin"))) + _ikke_fundet(request) + xml = podcast.feed(name, f"/podcast/{key}", str(request.base_url).rstrip("/"), med_kladder=bool(v.get("admin"))) if xml is None: raise HTTPException(404) return Response(xml, media_type="application/rss+xml; charset=utf-8", @@ -438,17 +500,18 @@ async def podcast_feed(request: Request, key: str, name: str): @app.get("/podcast/{key}/{name}/cover.jpg") -async def podcast_cover(key: str, name: str): - if not podcast.KEY.match(key) or not any(k["key"] == key and not k.get("tilbagekaldt") for k in podcast.alle_noegler()): - raise HTTPException(404) +async def podcast_cover(request: Request, key: str, name: str): p = podcast.cover(name) - if not p: - raise HTTPException(404) + if (podcast.blokeret(_klient_ip(request)) or not p or not podcast.KEY.match(key) + or not any(k["key"] == key and not k.get("tilbagekaldt") for k in podcast.alle_noegler())): + _ikke_fundet(request) return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"}) @app.get("/podcast/{key}/{name}/{slug}.mp3") async def podcast_mp3(request: Request, key: str, name: str, slug: str): + if podcast.blokeret(_klient_ip(request)): + _ikke_fundet(request) p = podcast.episode_fil(name, slug) rng = request.headers.get("range", "") # Apps henter i bidder; kun en hentning fra filens start tælles som en afspilning. @@ -458,7 +521,7 @@ async def podcast_mp3(request: Request, key: str, name: str, slug: str): else: v = next((k for k in podcast.alle_noegler() if k["key"] == key and not k.get("tilbagekaldt")), None) if not v or not p: - raise HTTPException(404) + _ikke_fundet(request) meta = podcast.episode_meta(name, slug) or {} if meta.get("kladde") and not v.get("admin"): raise HTTPException(404) @@ -471,10 +534,27 @@ async def admin_podcast(request: Request, saved: str = ""): user = require_admin(request) return templates.TemplateResponse(request, "admin_podcast.html", { "request": request, "user": user, "is_admin": True, "saved": saved, - "noegler": podcast.alle_noegler(), + "noegler": podcast.alle_noegler(), "faelles": podcast.faelles(), + "base": str(request.base_url).rstrip("/"), "udsendelser": [{**podcast.show(n), "episoder": podcast.episodes(n, med_kladder=True)} for n in podcast.shows()]}) +@app.post("/admin/podcast/faelles") +async def admin_podcast_faelles(request: Request): + require_admin(request) + f = await request.form() + handling = f.get("handling") + if handling == "skift": + podcast.faelles_skift() + besked = "Nyt fælles link lavet. Det gamle virker ikke længere." + elif handling in ("til", "fra"): + podcast.faelles_aktiv(handling == "til") + besked = "Fælles link slået til" if handling == "til" else "Fælles link slået fra" + else: + besked = "Ukendt handling" + return RedirectResponse(f"/admin/podcast?saved={urllib.parse.quote(besked)}", status_code=303) + + @app.post("/admin/podcast/noegle/{key}") async def admin_podcast_noegle(request: Request, key: str): require_admin(request) diff --git a/podcast.py b/podcast.py index 7c3fc03..dead203 100644 --- a/podcast.py +++ b/podcast.py @@ -11,6 +11,7 @@ Data (ikke i git, persondata): DATA/podcast//episodes/.mp3 selve episoden DATA/podcast//episodes/.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/" eller "/podcast/f/".""" 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: """ + + +# ---------------- fælles familielink ---------------- +# Ét link uden login til fx familiens Messenger-gruppe: /podcast/f// 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) diff --git a/templates/admin_podcast.html b/templates/admin_podcast.html index 8d221e4..4f1d9e4 100644 --- a/templates/admin_podcast.html +++ b/templates/admin_podcast.html @@ -34,6 +34,35 @@ {% endfor %} +

Fælles familielink

+

Ét link uden login, fx til familiens Messenger-gruppe: afspiller og feed i ét. Kladder vises aldrig her. Skifter du nøglen, holder det gamle link op med at virke med det samme, også for dem der abonnerer via det.

+
+ {% if faelles.key %} +

{{ 'Slået til' if faelles.aktiv else 'Slået fra' }} + · {{ faelles.hentninger or 0 }} hentninger{% if faelles.sidst %} · sidst {% endif %}

+ {% if faelles.aktiv %} +
+ + +
+ {% endif %} +
+ + {% if faelles.aktiv %} + + {% else %} + + {% endif %} +
+ {% else %} +
+ +
+ {% endif %} +
+

Lytternes adresser

diff --git a/templates/base.html b/templates/base.html index b55591b..8c1b97b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -13,6 +13,7 @@ +{% block head %}{% endblock %}
diff --git a/templates/podcast.html b/templates/podcast.html index 0d06b1a..85bfbb6 100644 --- a/templates/podcast.html +++ b/templates/podcast.html @@ -1,5 +1,7 @@ {% extends "base.html" %} -{% block title %}Podcast{% endblock %} +{% block title %}{% if delt and udsendelser %}{{ udsendelser[0].titel }}{% else %}Podcast{% endif %}{% endblock %} +{% block head %}{% if delt %} +{% endif %}{% endblock %} {% block body %} {% if saved %}
{{ saved }}
{% endif %} {% if not udsendelser %} @@ -10,7 +12,7 @@
{% if u.har_cover %} - {% endif %}
@@ -21,7 +23,7 @@

Abonnér i din podcast-app

-

Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.

+

{% if delt %}Tryk på din app, så kommer nye episoder af sig selv. Linket er familiens fælles, så del det kun inden for familien.{% else %}Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.{% endif %}

Apple Podcasts Overcast @@ -48,9 +50,9 @@ {% if e.kladde %}Kladde, kun du kan se den · {% endif %}{{ (e.varighed // 60) }} min
- {% if e.beskrivelse %}

{{ e.beskrivelse }}

{% endif %} + {% if e.beskrivelse %}

{{ e.beskrivelse }}

{% endif %} + src="{{ rod }}/{{ u.slug }}/{{ e.slug }}.mp3"> {% else %}

Ingen episoder endnu.

@@ -58,7 +60,7 @@
{% endfor %} -{% if udsendelser %} +{% if udsendelser and not delt %}
BrugerOprettetSidst hentetHentningerApp