Compare commits

..
3 Commits
Author SHA1 Message Date
agw 94e5a0ca01 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
2026-10-04 20:11:28 +02:00
agw 4274047158 feat(podcast): familiens podcast med personlige feeds
CD / deploy (push) Successful in 16s
CI / check (push) Successful in 22s
/podcast viser familiens podcasts (Slægtens Stemmer) med afspiller og en
abonnér-knap til Apple Podcasts, Overcast, Pocket Casts og AntennaPod. Podcast-
apps kan ikke logge ind med Authentik, så hver lytter får sin egen hemmelige
nøgle i adressen (/podcast/<nøgle>/<show>.xml); mp3-filer og omslag ligger
under samme nøgle. En nøgle kan trækkes tilbage i /admin/podcast uden at de
andre mærker det, og admin ser hvem der henter hvad.

Episoder kan ligge som kladde: så ses de kun af administratorer, også i deres
eget feed, indtil de udgives fra admin-siden. Adgang: gruppen PODCAST_GROUP
(standard Familie) og admins. Forsiden får et kort, når der er episoder.

Data ligger i DATA/podcast/ og kommer aldrig i git. mutagen læser varigheden.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-04 19:31:11 +02:00
agw 58471b1a4d fix(claude-login): siden opgiver ikke mens serveren starter login
CD / deploy (push) Successful in 20s
CI / check (push) Successful in 27s
Efter et tryk på Start kunne siden nå at læse den gamle hvile-status (fx
"Logget ind"), før serveren havde skrevet "starter". Så sprang den tilbage
til knappen og målte først igen efter 30 sekunder, og login-knappen kom
aldrig frem. Nu venter siden i op til et minut på en status, serveren har
skrevet efter trykket, og måler hvert 2. sekund imens.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-03 09:03:48 +02:00
9 changed files with 735 additions and 2 deletions
+4
View File
@@ -13,6 +13,8 @@ API_TOKEN = os.environ["AUTHENTIK_API_TOKEN"]
SECRET_KEY = os.environ["SESSION_SECRET"]
REDIRECT_URI = os.environ.get("OIDC_REDIRECT_URI", "https://weircon.dk/auth/callback")
ADMIN_GROUP = os.environ.get("ADMIN_GROUP", "authentik Admins")
# Hvem der må høre familiens podcasts (/podcast); admins kan altid.
PODCAST_GROUP = os.environ.get("PODCAST_GROUP", "Familie")
MATOMO_BASE = os.environ.get("MATOMO_BASE", "https://matomo.weircon.dk").rstrip("/")
MATOMO_TOKEN = os.environ.get("MATOMO_TOKEN", "")
@@ -106,6 +108,8 @@ ADMIN_MODULES = [
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
{"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏",
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
{"key": "podcast", "title": "Podcast", "icon": "🎙️",
"desc": "Slægtens Stemmer: udgiv kladder og se/træk lytternes personlige feed-adresser tilbage."},
{"key": "claude-login", "title": "Claude-login", "icon": "🤖",
"desc": "Log Claude på serveren ind igen fra telefonen, uden terminal."},
{"key": "mcp-keys", "title": "MCP-nøgler", "icon": "🔑",
+205 -2
View File
@@ -2,7 +2,7 @@
Forretningslogik ligger i moduler: config, cache, store, services, authentik."""
from authlib.integrations.starlette_client import OAuth
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
@@ -23,6 +23,7 @@ import mcp_server
import mcp_gateway
import secretdrop
import claudelogin
import podcast
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
@@ -69,6 +70,19 @@ def require_admin(request: Request):
return user
def require_familie(request: Request):
"""Logget ind og i PODCAST_GROUP (eller admin). Uden login: login og tilbage hertil."""
user = current_user(request)
if not user and request.method == "GET":
nxt = request.url.path + (f"?{request.url.query}" if request.url.query else "")
raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")})
if not user:
raise HTTPException(401, "Log ind først")
if not (is_admin(user) or config.PODCAST_GROUP in (user.get("groups") or [])):
raise HTTPException(403, "Kun for familien")
return user
def _sikker_next(nxt: str | None) -> str:
# Kun stier på denne side; "//host" og "/\\host" ville sende videre ud af huset.
if nxt and nxt.startswith("/") and not nxt.startswith(("//", "/\\")):
@@ -133,7 +147,20 @@ async def dashboard_content(request: Request):
"request": request, "error": error, "groups": ak.group_apps(apps), "apps_sorted": by_name,
"fav_apps": [a for a in by_name if a["slug"] in fav_set],
"favorites": list(fav_set), "view": prefs["view"],
"mc_servers": mc_servers, "disco_url": config.DISCO_PANEL_URL})
"mc_servers": mc_servers, "disco_url": config.DISCO_PANEL_URL,
"podcasts": _podcast_kort(user)})
def _podcast_kort(user) -> list[dict]:
"""Kort til forsiden: én pr. podcast med udgivne episoder (admins ser også kladder)."""
if not (is_admin(user) or config.PODCAST_GROUP in (user.get("groups") or [])):
return []
ud = []
for name in podcast.shows():
eps = podcast.episodes(name, med_kladder=is_admin(user))
if eps:
ud.append({**podcast.show(name), "antal": len(eps), "nyeste": eps[0]})
return ud
# ---------------- admin ----------------
@@ -370,6 +397,182 @@ async def admin_secrets_delete(request: Request, navn: str):
return RedirectResponse(f"/admin/secrets?saved={'Slettet' if ok else 'Fandtes ikke'}", status_code=303)
# ---------------- 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)
rod = f"/podcast/{key}"
return templates.TemplateResponse(request, "podcast.html", {
"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")
async def podcast_ny_noegle(request: Request):
user = require_familie(request)
podcast.ny_noegle(user["username"], is_admin(user))
return RedirectResponse("/podcast?saved=Ny+adresse+lavet.+Abonnér+igen+i+din+app;+den+gamle+virker+ikke+længere.",
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:
_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",
headers={"Cache-Control": "private, max-age=300", "X-Robots-Tag": "noindex, nofollow"})
@app.get("/podcast/{key}/{name}/cover.jpg")
async def podcast_cover(request: Request, key: str, name: str):
p = podcast.cover(name)
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.
fra_start = not rng or rng.replace(" ", "").startswith("bytes=0-")
if fra_start:
v = podcast.slaa_op(key, request.headers.get("user-agent", ""), slug)
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:
_ikke_fundet(request)
meta = podcast.episode_meta(name, slug) or {}
if meta.get("kladde") and not v.get("admin"):
raise HTTPException(404)
return FileResponse(p, media_type="audio/mpeg",
headers={"Cache-Control": "private, max-age=86400", "X-Robots-Tag": "noindex, nofollow"})
@app.get("/admin/podcast", response_class=HTMLResponse)
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(),
"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)
f = await request.form()
ok = podcast.tilbagekald(key, aktiv=f.get("aktiv") == "1")
return RedirectResponse(f"/admin/podcast?saved={'Gemt' if ok else 'Ukendt nøgle'}", status_code=303)
@app.post("/admin/podcast/{name}/{slug}/udgiv")
async def admin_podcast_udgiv(request: Request, name: str, slug: str):
require_admin(request)
f = await request.form()
kladde = f.get("kladde") == "1"
ok = podcast.saet_kladde(name, slug, kladde)
besked = ("Lagt tilbage som kladde" if kladde else "Udgivet til familien") if ok else "Ukendt episode"
return RedirectResponse(f"/admin/podcast?saved={besked}", status_code=303)
@app.get("/admin/claude-login", response_class=HTMLResponse)
async def admin_claude_login(request: Request):
user = require_admin(request)
+324
View File
@@ -0,0 +1,324 @@
"""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)
+1
View File
@@ -6,3 +6,4 @@ httpx
itsdangerous
python-multipart
maxminddb
mutagen
+6
View File
@@ -48,6 +48,9 @@
};
var $ = function (id) { return document.getElementById(id); };
var timer = null;
// Efter et tryk på Start venter vi i op til et minut på en status, serveren har
// skrevet EFTER trykket; en ældre hvile-status betyder bare, at den ikke er nået dertil.
var klik = 0;
function dato(t) {
var d = new Date(t * 1000);
@@ -56,6 +59,8 @@
function vis(st) {
var s = st.status || "ukendt";
var hvile = s === "ok" || s === "snart" || s === "udloebet" || s === "ukendt";
if (hvile && klik && Date.now() - klik < 60000 && (st.updated || 0) * 1000 < klik) s = "starter";
var linje = TEKST[s] || s;
if (st.expires && (s === "ok" || s === "snart")) {
var dage = Math.floor((st.expires * 1000 - Date.now()) / 86400000);
@@ -93,6 +98,7 @@
$("cl-start-btn").addEventListener("click", function () {
$("cl-start-btn").disabled = true;
klik = Date.now();
fetch("/admin/claude-login/start", { method: "POST", credentials: "same-origin" })
.then(function (r) { return r.json(); }).then(vis)
.finally(function () { $("cl-start-btn").disabled = false; });
+98
View File
@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block title %}Admin — podcast{% endblock %}
{% block body %}
<p><a href="/admin" class="wt-muted">← Admin</a></p>
<h1>Podcast</h1>
<p class="wt-lead">Kladder ses kun af administratorer, også i deres podcast-app. Udgiv en episode, når den er klar til familien. Lytternes adresser er personlige; trækker du en tilbage, holder den op med at virke, og de andre mærker intet. Personen får en ny adresse næste gang de åbner siden, så længe de er i gruppen Familie; skal nogen helt ud, så fjern dem fra gruppen i Authentik. Siden for familien er <a href="/podcast">/podcast</a>.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
{% for u in udsendelser %}
<h2 style="font-size:1.15rem;margin-top:1.6rem">{{ u.titel }}</h2>
<table class="wt-table">
<thead><tr><th>Nr</th><th>Titel</th><th>Varighed</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for e in u.episoder %}
<tr>
<td>{{ e.nr }}</td>
<td>{{ e.titel }}<div class="wt-muted" style="font-size:.8rem">{{ e.slug }}.mp3 · {{ (e.bytes / 1048576) | round(1) }} MB</div></td>
<td>{{ (e.varighed // 60) }}:{{ '%02d' % (e.varighed % 60) }}</td>
<td>{% if e.kladde %}Kladde{% else %}Udgivet {{ (e.udgivet or '')[:10] }}{% endif %}</td>
<td>
<form method="post" action="/admin/podcast/{{ u.slug }}/{{ e.slug }}/udgiv" style="margin:0">
{% if e.kladde %}
<button class="wt-btn wt-btn--primary" type="submit">Udgiv</button>
{% else %}
<input type="hidden" name="kladde" value="1"><button class="wt-btn" type="submit">Gør til kladde</button>
{% endif %}
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="wt-muted">Ingen episoder. Udgiv med publish.py i skillen slaegtens-stemmer.</td></tr>
{% endfor %}
</tbody>
</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>
<tbody>
{% for k in noegler %}
<tr{% if k.tilbagekaldt %} style="opacity:.55"{% endif %}>
<td>{{ k.user }}{% if k.admin %} <span class="wt-muted">(admin)</span>{% endif %}</td>
<td data-ts="{{ k.oprettet }}"></td>
<td data-ts="{{ k.sidst or '' }}"></td>
<td title="{% for h, n in (k.hentet or {}).items() %}{{ h }}: {{ n }}&#10;{% endfor %}">{{ k.hentninger or 0 }}</td>
<td class="wt-muted" style="font-size:.8rem;max-width:16rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ k.sidst_ua or '' }}</td>
<td>
<form method="post" action="/admin/podcast/noegle/{{ k.key }}" style="margin:0">
{% if k.tilbagekaldt %}
<input type="hidden" name="aktiv" value="1"><button class="wt-btn" type="submit">Genaktivér</button>
{% else %}
<button class="wt-btn" type="submit" onclick="return confirm('Træk {{ k.user }}s adresse tilbage?')">Træk tilbage</button>
{% endif %}
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="wt-muted">Ingen endnu. En lytter får sin adresse første gang de åbner /podcast.</td></tr>
{% endfor %}
</tbody>
</table>
<script>
document.querySelectorAll('[data-ts]').forEach(function (td) {
var t = parseInt(td.dataset.ts, 10);
td.textContent = t ? new Date(t * 1000).toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'short' }) : '–';
});
</script>
{% endblock %}
+1
View File
@@ -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">
+15
View File
@@ -25,6 +25,21 @@
<h2>★ Favoritter</h2>
<div class="grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
</section>
{% if podcasts %}
<section class="appgroup" id="podsec">
<h2>🎙️ Familiens podcast</h2>
<div class="grid">
{% for p in podcasts %}
<div class="card" data-search="podcast {{ (p.titel ~ ' ' ~ p.nyeste.titel)|lower }}">
<a class="card-link" href="/podcast" aria-label="{{ p.titel }}"></a>
<div class="head"><span class="ico">🎙️</span><span class="name">{{ p.titel }}</span></div>
<div class="sub" title="{{ p.nyeste.titel }}">{{ p.antal }} episode{{ 'r' if p.antal != 1 }} · nyeste: {{ p.nyeste.titel }}</div>
<div class="go">Lyt →</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if mc_servers %}
<section class="appgroup" id="mcsec">
<h2>⛏️ Minecraft-servere</h2>
+81
View File
@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% 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 %}
<h1>Podcast</h1>
<p class="wt-muted">Der er ingen podcasts endnu.</p>
{% endif %}
{% for u in udsendelser %}
<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="{{ 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">
<h1 style="margin:.1rem 0 .4rem">{{ u.titel }}</h1>
<p class="wt-lead" style="margin:0">{{ u.beskrivelse }}</p>
</div>
</div>
<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">{% 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>
<a class="wt-btn" href="pktc://subscribe/{{ u.uden_skema }}">Pocket Casts</a>
<a class="wt-btn" href="https://antennapod.org/deeplink/subscribe?url={{ u.feed_enc }}">AntennaPod</a>
</div>
<details style="margin-top:.9rem">
<summary class="wt-muted" style="cursor:pointer">Anden app? Kopiér adressen</summary>
<div style="display:flex;gap:.5rem;margin-top:.5rem;flex-wrap:wrap">
<input class="wt-input" readonly value="{{ u.feed }}" id="feed-{{ u.slug }}"
style="flex:1 1 20rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
<button class="wt-btn" type="button" data-kopier="feed-{{ u.slug }}">Kopiér</button>
</div>
<p class="wt-muted" style="font-size:.85rem;margin:.5rem 0 0">Spotify kan ikke abonnere på private podcasts. På Android virker AntennaPod og Pocket Casts.</p>
</details>
</div>
<h2 style="font-size:1.15rem">Episoder</h2>
{% for e in u.episoder %}
<article class="wt-card" style="margin:.8rem 0;padding:1rem 1.2rem">
<div style="display:flex;justify-content:space-between;gap:1rem;flex-wrap:wrap">
<h3 style="margin:0;font-size:1.05rem">{% if e.nr %}{{ e.nr }}. {% endif %}{{ e.titel }}</h3>
<span class="wt-muted" style="font-size:.85rem">
{% 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;white-space:pre-line">{{ e.beskrivelse }}</p>{% endif %}
<audio controls preload="none" style="width:100%;margin-top:.4rem"
src="{{ rod }}/{{ u.slug }}/{{ e.slug }}.mp3"></audio>
</article>
{% else %}
<p class="wt-muted">Ingen episoder endnu.</p>
{% endfor %}
</section>
{% endfor %}
{% 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>
<span class="wt-muted" style="font-size:.85rem">Hvis du har delt din adresse ved et uheld.</span>
</form>
{% endif %}
<script>
document.querySelectorAll('[data-kopier]').forEach(function (b) {
b.addEventListener('click', function () {
var i = document.getElementById(b.dataset.kopier);
i.select();
(navigator.clipboard ? navigator.clipboard.writeText(i.value) : Promise.reject())
.catch(function () { document.execCommand('copy'); })
.finally(function () { b.textContent = 'Kopieret'; });
});
});
</script>
{% endblock %}