Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94e5a0ca01 |
@@ -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/<nøgle>" eller "/podcast/f/<fælles nøgle>"."""
|
||||
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)
|
||||
|
||||
+70
-2
@@ -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)
|
||||
|
||||
@@ -34,6 +34,35 @@
|
||||
</table>
|
||||
{% endfor %}
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:2rem">Fælles familielink</h2>
|
||||
<p class="wt-muted" style="margin:.3rem 0 .8rem">É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.</p>
|
||||
<div class="wt-card" style="padding:1rem 1.2rem;max-width:48rem">
|
||||
{% if faelles.key %}
|
||||
<p style="margin:0 0 .6rem"><strong>{{ 'Slået til' if faelles.aktiv else 'Slået fra' }}</strong>
|
||||
· {{ faelles.hentninger or 0 }} hentninger{% if faelles.sidst %} · sidst <span data-ts="{{ faelles.sidst }}"></span>{% endif %}</p>
|
||||
{% if faelles.aktiv %}
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:.8rem">
|
||||
<input class="wt-input" readonly id="faelles-link" value="{{ base }}/podcast/f/{{ faelles.key }}/"
|
||||
style="flex:1 1 22rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
|
||||
<button class="wt-btn" type="button" onclick="var i=document.getElementById('faelles-link');i.select();navigator.clipboard&&navigator.clipboard.writeText(i.value);this.textContent='Kopieret'">Kopiér</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/podcast/faelles" style="display:flex;gap:.5rem;flex-wrap:wrap;margin:0">
|
||||
<button class="wt-btn" type="submit" name="handling" value="skift"
|
||||
onclick="return confirm('Det gamle fælles link holder op med at virke. Fortsæt?')">Skift nøgle</button>
|
||||
{% if faelles.aktiv %}
|
||||
<button class="wt-btn" type="submit" name="handling" value="fra">Slå fra</button>
|
||||
{% else %}
|
||||
<button class="wt-btn wt-btn--primary" type="submit" name="handling" value="til">Slå til</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/admin/podcast/faelles" style="margin:0">
|
||||
<button class="wt-btn wt-btn--primary" type="submit" name="handling" value="skift">Lav fælles link</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:2rem">Lytternes adresser</h2>
|
||||
<table class="wt-table">
|
||||
<thead><tr><th>Bruger</th><th>Oprettet</th><th>Sidst hentet</th><th>Hentninger</th><th>App</th><th></th></tr></thead>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<link rel="stylesheet" href="https://assets.weircon.dk/css/weirsoe-theme.css" />
|
||||
<link rel="stylesheet" href="/static/portal.css" />
|
||||
<script defer src="https://assets.weircon.dk/js/weirsoe-theme.js"></script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header class="wt-header">
|
||||
|
||||
@@ -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 %}<meta name="robots" content="noindex, nofollow, noarchive">
|
||||
<meta name="referrer" content="no-referrer">{% endif %}{% endblock %}
|
||||
{% block body %}
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
{% if not udsendelser %}
|
||||
@@ -10,7 +12,7 @@
|
||||
<section style="margin:1rem 0 2.5rem">
|
||||
<div style="display:flex;gap:1.2rem;align-items:flex-start;flex-wrap:wrap">
|
||||
{% if u.har_cover %}
|
||||
<img src="/podcast/{{ key }}/{{ u.slug }}/cover.jpg" alt="" width="160" height="160"
|
||||
<img src="{{ rod }}/{{ u.slug }}/cover.jpg" alt="" width="160" height="160"
|
||||
style="border-radius:14px;flex:0 0 auto;box-shadow:0 4px 18px rgba(0,0,0,.25)">
|
||||
{% endif %}
|
||||
<div style="flex:1 1 18rem;min-width:0">
|
||||
@@ -21,7 +23,7 @@
|
||||
|
||||
<div class="wt-card" style="margin:1.4rem 0;padding:1rem 1.2rem">
|
||||
<h2 style="font-size:1.05rem;margin:0 0 .5rem">Abonnér i din podcast-app</h2>
|
||||
<p class="wt-muted" style="margin:.2rem 0 .8rem">Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.</p>
|
||||
<p class="wt-muted" style="margin:.2rem 0 .8rem">{% 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 %}</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.5rem">
|
||||
<a class="wt-btn wt-btn--primary" href="podcast://{{ u.uden_skema }}">Apple Podcasts</a>
|
||||
<a class="wt-btn" href="overcast://x-callback-url/add?url={{ u.feed_enc }}">Overcast</a>
|
||||
@@ -48,9 +50,9 @@
|
||||
{% if e.kladde %}<strong>Kladde, kun du kan se den</strong> · {% endif %}{{ (e.varighed // 60) }} min
|
||||
</span>
|
||||
</div>
|
||||
{% if e.beskrivelse %}<p style="margin:.5rem 0">{{ e.beskrivelse }}</p>{% endif %}
|
||||
{% if e.beskrivelse %}<p style="margin:.5rem 0;white-space:pre-line">{{ e.beskrivelse }}</p>{% endif %}
|
||||
<audio controls preload="none" style="width:100%;margin-top:.4rem"
|
||||
src="/podcast/{{ key }}/{{ u.slug }}/{{ e.slug }}.mp3"></audio>
|
||||
src="{{ rod }}/{{ u.slug }}/{{ e.slug }}.mp3"></audio>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen episoder endnu.</p>
|
||||
@@ -58,7 +60,7 @@
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
{% if udsendelser %}
|
||||
{% if udsendelser and not delt %}
|
||||
<form method="post" action="/podcast/ny-noegle" style="margin:2rem 0"
|
||||
onsubmit="return confirm('Din gamle adresse holder op med at virke, og du skal abonnere igen i din app. Fortsæt?')">
|
||||
<button class="wt-btn" type="submit">Lav en ny adresse</button>
|
||||
|
||||
Reference in New Issue
Block a user