From 86ab8f2a19995a5531a046b111662597041998ab Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Thu, 5 Nov 2026 21:31:00 +0100 Subject: [PATCH] =?UTF-8?q?feat(podcast):=20del=20med=20ukendte,=20g=C3=A6?= =?UTF-8?q?stelinks=20med=20navn=20og=20udl=C3=B8b=20der=20kan=20tr=C3=A6k?= =?UTF-8?q?kes=20tilbage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu --- main.py | 40 ++++++++++++++++---- podcast.py | 72 ++++++++++++++++++++++++++++++++++++ templates/admin_podcast.html | 48 ++++++++++++++++++++++++ templates/podcast.html | 5 ++- tests/podcast_gaest_smoke.py | 51 +++++++++++++++++++++++++ 5 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 tests/podcast_gaest_smoke.py diff --git a/main.py b/main.py index 2171f59..b846fa7 100644 --- a/main.py +++ b/main.py @@ -468,8 +468,10 @@ _INDBYGGET = re.compile(r"FBAN|FBAV|FB_IAB|FBIOS|Messenger|Instagram|LinkedInApp async def podcast_faelles_side(request: Request, key: str): ua = request.headers.get("user-agent", "") forhaandsvisning = bool(_FORHAANDSVISNING.search(ua)) - if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key, "" if forhaandsvisning else "side"): + delt = None if podcast.blokeret(_klient_ip(request)) else podcast.delt_slaa_op(key, "" if forhaandsvisning else "side") + if not delt: _ikke_fundet(request) + gaest = delt if delt["slags"] == "gaest" else None rod = f"/podcast/f/{key}" base = str(request.base_url).rstrip("/") udsendelser = _podcast_kontekst(request, rod, med_kladder=False) @@ -478,7 +480,8 @@ async def podcast_faelles_side(request: Request, key: str): # stedet for et billede i hele beskedens bredde. lille = bool(s.get("slug")) and podcast.cover(s["slug"], lille=True) is not None og = {"titel": s["titel"], "url": f"{base}{rod}/", - "beskrivelse": "Familiens podcast. Lyt i browseren, eller abonnér i din podcast-app.", + "beskrivelse": ("Lyt i browseren, eller abonnér i din podcast-app." if gaest else + "Familiens podcast. Lyt i browseren, eller abonnér i din podcast-app."), "billede": (f"{base}{rod}/{s['slug']}/cover-lille.jpg" if lille else f"{base}{rod}/{s['slug']}/cover.jpg" if s.get("har_cover") else ""), "str": 300 if lille else 3000} @@ -502,12 +505,12 @@ async def podcast_faelles_side(request: Request, key: str): indbygget = {"platform": "andet"} return templates.TemplateResponse(request, "podcast.html", { "request": request, "user": None, "is_admin": False, "rod": rod, "delt": True, "og": og, - "indbygget": indbygget, "udsendelser": udsendelser, "saved": ""}, headers=headers) + "indbygget": indbygget, "gaest": gaest, "udsendelser": udsendelser, "saved": ""}, headers=headers) @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"): + if podcast.blokeret(_klient_ip(request)) or not podcast.delt_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: @@ -519,7 +522,7 @@ async def podcast_faelles_feed(request: Request, key: str, name: str): @app.get("/podcast/f/{key}/{name}/cover-lille.jpg") async def podcast_faelles_cover_lille(request: Request, key: str, name: str): p = podcast.cover(name, lille=True) - if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key) or not p: + if podcast.blokeret(_klient_ip(request)) or not podcast.delt_slaa_op(key) or not p: _ikke_fundet(request) return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"}) @@ -527,7 +530,7 @@ async def podcast_faelles_cover_lille(request: Request, key: str, name: str): @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: + if podcast.blokeret(_klient_ip(request)) or not podcast.delt_slaa_op(key) or not p: _ikke_fundet(request) return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"}) @@ -539,7 +542,7 @@ async def podcast_faelles_mp3(request: Request, key: str, name: str, slug: str): 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 "")): + or not podcast.delt_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"}) @@ -594,7 +597,7 @@ 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(), "faelles": podcast.faelles(), + "noegler": podcast.alle_noegler(), "faelles": podcast.faelles(), "gaester": podcast.alle_gaester(), "base": str(request.base_url).rstrip("/"), "udsendelser": [{**podcast.show(n), "episoder": podcast.episodes(n, med_kladder=True)} for n in podcast.shows()]}) @@ -615,6 +618,27 @@ async def admin_podcast_faelles(request: Request): return RedirectResponse(f"/admin/podcast?saved={urllib.parse.quote(besked)}", status_code=303) +@app.post("/admin/podcast/gaest") +async def admin_podcast_gaest(request: Request): + require_admin(request) + f = await request.form() + handling, key = f.get("handling"), f.get("key") or "" + if handling == "ny": + try: + dage = max(0, min(int(f.get("dage") or 30), 365)) + except ValueError: + dage = 30 + podcast.gaest_ny(f.get("navn") or "", dage) + besked = "Gæstelink lavet. Kopiér det i listen herunder." + elif handling == "tilbagekald": + besked = "Gæstelinket virker ikke længere" if podcast.gaest_tilbagekald(key) else "Ukendt link" + elif handling == "slet": + besked = "Gæstelinket er slettet" if podcast.gaest_slet(key) else "Ukendt link" + 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 edb0a5b..9ad1a89 100644 --- a/podcast.py +++ b/podcast.py @@ -12,6 +12,7 @@ Data (ikke i git, persondata): 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) + DATA/podcast_gaester.json gæstelinks til ukendte (navn, udløb, kan trækkes tilbage) 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. @@ -31,6 +32,7 @@ import config ROOT = config.DATA / "podcast" KEYS = config.DATA / "podcast_keys.json" DELT = config.DATA / "podcast_faelles.json" +GAEST = config.DATA / "podcast_gaester.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}$") @@ -303,6 +305,76 @@ def faelles_slaa_op(key: str, hvad: str = "") -> bool: return True +# ---------------- del med ukendte ---------------- +# Gæstelinks til folk uden for familien (fx en jobsamtale): hver har sit eget navn, +# sin egen nøgle og en udløbsdato, og kan trækkes tilbage uden at røre familiens +# link eller de personlige adresser. Samme side som det fælles link, aldrig kladder. + +def gaester() -> dict: + try: + return json.loads(GAEST.read_text()) + except (OSError, ValueError): + return {} + + +def alle_gaester() -> list[dict]: + nu = time.time() + ud = [{"key": key, **v, "udloebet": bool(v.get("udloeber") and v["udloeber"] < nu)} + for key, v in gaester().items()] + return sorted(ud, key=lambda v: (bool(v.get("tilbagekaldt") or v["udloebet"]), -v.get("oprettet", 0))) + + +def gaest_ny(navn: str, dage: int) -> str: + g = gaester() + key = secrets.token_urlsafe(32) + nu = int(time.time()) + g[key] = {"navn": navn.strip()[:80] or "Gæst", "oprettet": nu, + "udloeber": nu + dage * 86400 if dage > 0 else None, "hentninger": 0, "hentet": {}} + _skriv(GAEST, g) + return key + + +def gaest_tilbagekald(key: str) -> bool: + g = gaester() + if key not in g: + return False + g[key]["tilbagekaldt"] = int(time.time()) + _skriv(GAEST, g) + return True + + +def gaest_slet(key: str) -> bool: + g = gaester() + if g.pop(key, None) is None: + return False + _skriv(GAEST, g) + return True + + +def gaest_slaa_op(key: str, hvad: str = "") -> dict | None: + if not KEY.match(key or ""): + return None + g = gaester() + v = g.get(key) + if not v or v.get("tilbagekaldt") or (v.get("udloeber") and v["udloeber"] < time.time()): + return None + if hvad: + v["hentninger"] = v.get("hentninger", 0) + 1 + v["sidst"] = int(time.time()) + v.setdefault("hentet", {}) + v["hentet"][hvad] = v["hentet"].get(hvad, 0) + 1 + _skriv(GAEST, g) + return v + + +def delt_slaa_op(key: str, hvad: str = "") -> dict | None: + """Fælles familielink eller gæstelink -> {"slags": "familie"|"gaest", ...}, ellers None.""" + if faelles_slaa_op(key, hvad): + return {"slags": "familie"} + v = gaest_slaa_op(key, hvad) + return {"slags": "gaest", **v} if v else None + + # ---------------- 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 diff --git a/templates/admin_podcast.html b/templates/admin_podcast.html index 4f1d9e4..08528c8 100644 --- a/templates/admin_podcast.html +++ b/templates/admin_podcast.html @@ -63,6 +63,54 @@ {% endif %} +

Del med ukendte

+

Gæstelinks til folk uden for familien, fx til en jobsamtale. Hvert link har sin egen nøgle og udløber af sig selv; du kan også trække det tilbage før tid. Det har intet med familiens link eller de personlige adresser at gøre. Kladder vises aldrig.

+
+ + + + +
+ + + + {% for g in gaester %} + {% set doed = g.tilbagekaldt or g.udloebet %} + + + + + + + + + {% else %} + + {% endfor %} + +
TilLinkUdløberSidst brugtHentninger
{{ g.navn }}{% if doed %}{{ 'Trukket tilbage' if g.tilbagekaldt else 'Udløbet' }}{% else %} +
+ + +
{% endif %}
{% if g.udloeber %}{% else %}Aldrig{% endif %}{{ g.hentninger or 0 }} +
+ + {% if doed %} + + {% else %} + + {% endif %} +
+
Ingen gæstelinks.
+

Lytternes adresser

diff --git a/templates/podcast.html b/templates/podcast.html index 178ebb4..29da0d0 100644 --- a/templates/podcast.html +++ b/templates/podcast.html @@ -52,7 +52,7 @@ {% endif %} {% endif %} -

{% 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 %}

+

{% if gaest %}Tryk på din app, så kommer nye episoder af sig selv. Linket er delt med dig personligt{% if gaest.udloeber %} og virker til {% endif %}, så del det ikke videre.{% elif 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 @@ -108,6 +108,9 @@ function kopierTekst(t, knap) { }; if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(ok, fallback); else fallback(); } +document.querySelectorAll('[data-ts]').forEach(function (s) { + s.textContent = new Date(parseInt(s.dataset.ts, 10) * 1000).toLocaleDateString('da-DK', { dateStyle: 'long' }); +}); document.querySelectorAll('[data-kopier-tekst]').forEach(function (b) { b.addEventListener('click', function () { kopierTekst(b.dataset.kopierTekst, b); }); }); diff --git a/tests/podcast_gaest_smoke.py b/tests/podcast_gaest_smoke.py new file mode 100644 index 0000000..3bd70fd --- /dev/null +++ b/tests/podcast_gaest_smoke.py @@ -0,0 +1,51 @@ +# Røgtest af "Del med ukendte" (gæstelinks) med FastAPI TestClient, uden OIDC. +# Kør i CT151: PORTAL_DATA=/tmp/pg-test/data + env fra /etc/portal.env. Rører ikke prod-data. +import json, time +from fastapi.testclient import TestClient +import main, podcast + +d = podcast.ROOT / "test" +(d / "episodes").mkdir(parents=True, exist_ok=True) +(d / "show.json").write_text(json.dumps({"titel": "Testshow", "beskrivelse": "b"})) +for slug, kladde in (("e1", False), ("e2", True)): + (d / "episodes" / f"{slug}.mp3").write_bytes(b"\xff\xfb" + b"\0" * 2000) + (d / "episodes" / f"{slug}.json").write_text(json.dumps({"titel": slug, "nr": 1, "kladde": kladde})) + +c = TestClient(main.app, base_url="https://weircon.dk") +admin = {"username": "agw", "name": "Asger", "groups": [main.config.ADMIN_GROUP, "Familie"]} +main.current_user = lambda request: admin + +r = c.post("/admin/podcast/gaest", data={"handling": "ny", "navn": "Jobsamtale X", "dage": "14"}, follow_redirects=False) +assert r.status_code == 303, r.text +g = podcast.alle_gaester()[0]; key = g["key"] +assert 13.9 * 86400 < g["udloeber"] - time.time() < 14.1 * 86400 +r = c.get("/admin/podcast"); assert r.status_code == 200 and "Jobsamtale X" in r.text and key in r.text + +main.current_user = lambda request: None +r = c.get(f"/podcast/f/{key}/"); assert r.status_code == 200 and "delt med dig personligt" in r.text, r.status_code +assert "familiens fælles" not in r.text and "e2.mp3" not in r.text +assert c.get(f"/podcast/f/{key}/test.xml").status_code == 200 +assert c.get(f"/podcast/f/{key}/test/e1.mp3").status_code == 200 +assert c.get(f"/podcast/f/{key}/test/e2.mp3").status_code == 404, "kladde må ikke kunne hentes" +assert podcast.gaester()[key]["hentninger"] == 3 +assert not podcast.faelles().get("key"), "gæstelinket må ikke røre familielinket" + +# udløbet +gg = podcast.gaester(); gg[key]["udloeber"] = int(time.time()) - 1; podcast._skriv(podcast.GAEST, gg) +assert c.get(f"/podcast/f/{key}/").status_code == 404 +gg[key]["udloeber"] = int(time.time()) + 3600; podcast._skriv(podcast.GAEST, gg) +assert c.get(f"/podcast/f/{key}/").status_code == 200 + +# trukket tilbage, slettet +main.current_user = lambda request: admin +assert c.post("/admin/podcast/gaest", data={"handling": "tilbagekald", "key": key}, follow_redirects=False).status_code == 303 +assert c.get(f"/podcast/f/{key}/test/e1.mp3").status_code == 404 +assert c.post("/admin/podcast/gaest", data={"handling": "slet", "key": key}, follow_redirects=False).status_code == 303 +assert key not in podcast.gaester() + +# "aldrig" + familielinket virker stadig ved siden af +c.post("/admin/podcast/gaest", data={"handling": "ny", "navn": "Uden udløb", "dage": "0"}) +assert podcast.alle_gaester()[0]["udloeber"] is None +fk = podcast.faelles_skift() +r = c.get(f"/podcast/f/{fk}/"); assert r.status_code == 200 and "familiens fælles" in r.text +print("OK")
BrugerOprettetSidst hentetHentningerApp