"""Weircon Portal — FastAPI-app: OIDC-login, async dashboard + udvidbar admin. Forretningslogik ligger i moduler: config, cache, store, services, authentik.""" from authlib.integrations.starlette_client import OAuth from fastapi import FastAPI, Form, HTTPException, Request from starlette.background import BackgroundTask 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 import asyncio import contextlib import logging import re import time import urllib.parse import authentik as ak import cache import config import mediaclean as mc import services as svc import store import versions import ytdl as yt import mcpkeys import mcp_server import mcp_gateway import secretdrop import claudelogin import podcast import erindringer import epub # Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True # => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated. mcp_app = mcp_gateway.gateway.http_app(path="/mcp", json_response=True) @contextlib.asynccontextmanager async def _lifespan(a): _i_baggrunden(epub.fejer()) # rydder udløbne /e-pub-jobs, også dem fra før en genstart async with mcp_app.lifespan(a): yield app = FastAPI(title="Weircon Portal", lifespan=_lifespan) for _mcp_route in mcp_app.routes: app.router.routes.append(_mcp_route) # route-extend copies routes but NOT middleware — carry FastMCP's auth/context # middleware over so the /mcp auth (OIDC + static dual-auth) actually runs. for _mcp_mw in reversed(mcp_app.user_middleware): app.add_middleware(_mcp_mw.cls, *_mcp_mw.args, **_mcp_mw.kwargs) app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True, same_site="lax", max_age=60 * 60 * 12) app.mount("/static", StaticFiles(directory=config.BASE / "static"), name="static") templates = Jinja2Templates(directory=str(config.BASE / "templates")) # Cache-busting af portalens eget CSS: uden Cache-Control cacher Safari heuristisk i timevis, # så en rettelse i portal.css nåede ikke telefonen. Versionen er filens ændringstid. templates.env.globals["static_v"] = str(int((config.BASE / "static" / "portal.css").stat().st_mtime)) oauth = OAuth() oauth.register( name="authentik", server_metadata_url=f"{config.AUTHENTIK}/application/o/weircon-portal/.well-known/openid-configuration", client_id=config.CLIENT_ID, client_secret=config.CLIENT_SECRET, client_kwargs={"scope": "openid email profile groups"}) def current_user(request: Request): return request.session.get("user") def is_admin(user) -> bool: return bool(user) and config.ADMIN_GROUP in (user.get("groups") or []) def require_admin(request: Request): user = current_user(request) if not user and request.method == "GET" and "text/html" in request.headers.get("accept", ""): # Ikke logget ind: login og tilbage hertil (fx et link fra en ntfy), # i stedet for en 403-blindgyde. 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): raise HTTPException(403, "Kun for administratorer") return user def require_familie(request: Request): """Logget ind og i en af PODCAST_GROUPS (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_GROUPS & set(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(("//", "/\\")): return nxt return "/" # ---------------- auth ---------------- @app.get("/auth/login") async def login(request: Request, next: str = ""): request.session["next"] = _sikker_next(next) return await oauth.authentik.authorize_redirect(request, config.REDIRECT_URI) @app.get("/auth/callback") async def callback(request: Request): try: token = await oauth.authentik.authorize_access_token(request) except Exception as e: return HTMLResponse(f"Login fejlede: {e}", status_code=400) info = token.get("userinfo") or {} request.session["user"] = { "name": info.get("name") or info.get("preferred_username"), "username": info.get("preferred_username"), "email": info.get("email"), "groups": info.get("groups") or []} return RedirectResponse(_sikker_next(request.session.pop("next", "/")), status_code=302) @app.get("/auth/logout") async def logout(request: Request): request.session.clear() return RedirectResponse("/", status_code=302) # ---------------- forside (async: shell straks, indhold via /dashboard-content) ---------------- @app.get("/", response_class=HTMLResponse) async def home(request: Request, saved: str = ""): user = current_user(request) if not user: return templates.TemplateResponse(request, "login.html", {"request": request}) return templates.TemplateResponse(request, "dashboard.html", { "request": request, "user": user, "is_admin": is_admin(user), "saved": saved}) @app.get("/dashboard-content", response_class=HTMLResponse) async def dashboard_content(request: Request): user = current_user(request) if not user: raise HTTPException(401) mc_task = asyncio.create_task(svc.minecraft_servers()) apps, error = [], None try: apps = await ak.fetch_user_apps(user["username"]) except Exception as e: error = f"Kunne ikke hente apps fra Authentik: {e}" mc_servers = await mc_task prefs = store.get_prefs(user["username"]) fav_set = set(prefs["favorites"]) by_name = sorted(apps, key=lambda x: (x["name"] or "").lower()) return templates.TemplateResponse(request, "dashboard_body.html", { "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, "podcasts": _podcast_kort(user), "erindringer": bool(is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or []))}) 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_GROUPS & set(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 ---------------- @app.get("/admin", response_class=HTMLResponse) async def admin_home(request: Request): user = require_admin(request) return templates.TemplateResponse(request, "admin.html", { "request": request, "user": user, "modules": config.ADMIN_MODULES, "is_admin": True}) @app.get("/admin/ip-labels", response_class=HTMLResponse) async def admin_iplabels(request: Request, saved: str = ""): user = require_admin(request) data = store.load_json(config.IP_LABELS_LOCAL, {}) rows = [{"ip": k, "label": (v or {}).get("label", ""), "country": (v or {}).get("country", "")} for k, v in data.items() if isinstance(v, dict)] rows.sort(key=lambda r: r["ip"]) raw = config.IP_LABELS_LOCAL.read_text("utf-8") if config.IP_LABELS_LOCAL.exists() else "{}" return templates.TemplateResponse(request, "admin_iplabels.html", { "request": request, "user": user, "is_admin": True, "rows": rows, "raw": raw, "saved": saved}) @app.post("/admin/ip-labels") async def admin_iplabels_save(request: Request): require_admin(request) form = await request.form() data = {} for ip, label, country in zip(form.getlist("ip"), form.getlist("label"), form.getlist("country")): ip = (ip or "").strip() if not ip: continue entry = {} if (label or "").strip(): entry["label"] = label.strip() if (country or "").strip(): entry["country"] = country.strip().lower() if entry: data[ip] = entry return RedirectResponse(f"/admin/ip-labels?saved={store.save_ip_labels(data)}", status_code=303) @app.get("/admin/cookies", response_class=HTMLResponse) async def admin_cookies(request: Request, saved: str = ""): user = require_admin(request) s = store.load_json(config.SETTINGS_PATH, {}) return templates.TemplateResponse(request, "admin_cookies.html", { "request": request, "user": user, "is_admin": True, "banner": s.get("cookie_banner", "Denne side bruger cookies til at forbedre din oplevelse og lave anonym statistik over besøg. Ved at bruge siden accepterer du det."), "page": s.get("cookie_page", "Vi bruger Matomo (self-hosted) til anonym statistik og et par funktionelle cookies. Intet deles med tredjeparter."), "saved": saved}) @app.post("/admin/cookies") async def admin_cookies_save(request: Request, cookie_banner: str = Form(""), cookie_page: str = Form("")): require_admin(request) import json s = store.load_json(config.SETTINGS_PATH, {}) s["cookie_banner"] = cookie_banner s["cookie_page"] = cookie_page config.SETTINGS_PATH.write_text(json.dumps(s, ensure_ascii=False, indent=2), "utf-8") return RedirectResponse("/admin/cookies?saved=Gemt", status_code=303) @app.get("/admin/ops", response_class=HTMLResponse) async def admin_ops(request: Request): # Hurtig shell uden data — indholdet hentes async fra /ops-content og pollers hvert 10s. user = require_admin(request) return templates.TemplateResponse(request, "admin_ops.html", { "request": request, "user": user, "is_admin": True}) @app.get("/ops-content", response_class=HTMLResponse) async def ops_content(request: Request): require_admin(request) version, rp, vers = await asyncio.gather( svc.assets_version(), svc.rp_status(), versions.all_versions()) return templates.TemplateResponse(request, "admin_ops_body.html", { "request": request, "version": version, "rp": rp, "versions": vers, "host": versions.host_updates()}) @app.get("/admin/stats", response_class=HTMLResponse) async def admin_stats(request: Request): user = require_admin(request) return templates.TemplateResponse(request, "admin_stats.html", { "request": request, "user": user, "is_admin": True, "stats": await svc.matomo_summary(), "matomo": config.MATOMO_BASE}) @app.get("/admin/apps", response_class=HTMLResponse) async def admin_apps(request: Request, saved: str = ""): user = require_admin(request) return templates.TemplateResponse(request, "admin_apps.html", { "request": request, "user": user, "is_admin": True, "rows": await ak.fetch_all_apps_admin(), "saved": saved}) @app.post("/admin/apps") async def admin_apps_save(request: Request): require_admin(request) form = await request.form() rows = [{"slug": sl, "desc": (d or "").strip(), "group": (g or "").strip()} for sl, d, g in zip(form.getlist("slug"), form.getlist("desc"), form.getlist("group")) if sl] n = await ak.patch_apps(rows) cache.clear("apps") return RedirectResponse(f"/?saved=Gemt {n} apps", status_code=303) @app.get("/admin/users", response_class=HTMLResponse) async def admin_users(request: Request, saved: str = ""): user = require_admin(request) return templates.TemplateResponse(request, "admin_users.html", { "request": request, "user": user, "is_admin": True, "users": await ak.list_users(), "groups": await ak.list_groups(), "saved": saved}) @app.post("/admin/users") async def admin_users_create(request: Request): require_admin(request) f = await request.form() username = (f.get("username") or "").strip() if not username: return RedirectResponse("/admin/users?saved=Brugernavn mangler", status_code=303) ok, res = await ak.create_user(username, (f.get("name") or "").strip(), (f.get("email") or "").strip(), f.get("password") or "", f.getlist("groups")) cache.clear("users", "apps") msg = f"Bruger '{username}' oprettet" if ok else f"Fejl: {res}" return RedirectResponse(f"/admin/users?saved={msg}", status_code=303) @app.get("/admin/users/{pk}", response_class=HTMLResponse) async def admin_user_edit(request: Request, pk: int): user = require_admin(request) return templates.TemplateResponse(request, "admin_user_edit.html", { "request": request, "user": user, "is_admin": True, "u": await ak.get_user(pk), "groups": await ak.list_groups()}) @app.post("/admin/users/{pk}") async def admin_user_save(request: Request, pk: int): user = require_admin(request) f = await request.form() groups = f.getlist("groups") # Guardrail: du kan ikke fjerne dig selv fra admin-gruppen (andre kan godt). me = await ak.user_by_username(user["username"]) admin_pk = await ak.admin_group_pk(config.ADMIN_GROUP) note = "" if me and str(pk) == me["pk"] and admin_pk in me["groups"] and admin_pk not in groups: groups = groups + [admin_pk] note = " (du kan ikke fjerne din egen admin-rolle — beholdt)" await ak.update_user(pk, groups, f.get("is_active") == "on", f.get("password") or "") cache.clear("users", "apps", "access") return RedirectResponse(f"/admin/users?saved=Bruger gemt{note}", status_code=303) INV_VARIGHED = [(30, "30 minutter"), (120, "2 timer"), (1440, "1 dag"), (4320, "3 dage"), (10080, "7 dage"), (20160, "14 dage"), (43200, "30 dage")] @app.get("/admin/invitations", response_class=HTMLResponse) async def admin_invitations(request: Request, ny: str = "", saved: str = ""): user = require_admin(request) grupper = [g for g in await ak.list_groups() if g["name"] not in ak.INV_SKJUL] navn = {str(g["pk"]): g["name"] for g in await ak.list_groups()} invs = await ak.list_invitations() from datetime import datetime, timezone nu = datetime.now(timezone.utc) for i in invs: i["gruppenavne"] = [navn.get(str(x), "?") for x in i["grupper"]] try: rest = datetime.fromisoformat(i["expires"].replace("Z", "+00:00")) - nu sek = int(rest.total_seconds()) i["udloebet"] = sek <= 0 i["rest"] = ("udløbet" if sek <= 0 else f"{sek // 86400} dage" if sek >= 86400 else f"{sek // 3600} timer" if sek >= 3600 else f"{max(sek // 60, 1)} min") except Exception: i["udloebet"], i["rest"] = False, "?" nyt = next((i for i in invs if str(i["pk"]) == ny), None) return templates.TemplateResponse(request, "admin_invitations.html", { "request": request, "user": user, "is_admin": True, "grupper": grupper, "invitationer": invs, "nyt": nyt, "varigheder": INV_VARIGHED, "saved": saved}) @app.post("/admin/invitations") async def admin_invitations_create(request: Request): require_admin(request) f = await request.form() grupper = f.getlist("grupper") minutter = int(f.get("minutter") or 20160) if minutter not in {m for m, _ in INV_VARIGHED}: minutter = 20160 if not grupper: return RedirectResponse("/admin/invitations?saved=Vælg mindst én gruppe", status_code=303) ok, res = await ak.create_invitation((f.get("label") or "").strip(), grupper, minutter, engangs=bool(f.get("engangs"))) if not ok: return RedirectResponse(f"/admin/invitations?saved=Kunne ikke oprette: {res[:120]}", status_code=303) return RedirectResponse(f"/admin/invitations?ny={res}", status_code=303) @app.post("/admin/invitations/{pk}/slet") async def admin_invitations_delete(request: Request, pk: str): require_admin(request) ok = await ak.delete_invitation(pk) return RedirectResponse(f"/admin/invitations?saved={'Invitationen er tilbagekaldt' if ok else 'Kunne ikke tilbagekalde'}", status_code=303) @app.get("/admin/secrets", response_class=HTMLResponse) async def admin_secrets(request: Request, saved: str = "", gemt: str = ""): user = require_admin(request) return templates.TemplateResponse(request, "admin_secrets.html", { "request": request, "user": user, "is_admin": True, "ventende": secretdrop.pending(), "saved": saved, "gemt": gemt}) @app.post("/admin/secrets") async def admin_secrets_store(request: Request): user = require_admin(request) f = await request.form() navn = (f.get("navn") or "").strip().lower() fejl = secretdrop.store(navn, f.get("vaerdi") or "", f.get("note") or "", (user or {}).get("preferred_username") or (user or {}).get("email") or "?") if fejl: return RedirectResponse(f"/admin/secrets?saved={fejl}", status_code=303) # Værdien sendes aldrig tilbage; kun navnet, så siden kan bekræfte det. return RedirectResponse(f"/admin/secrets?gemt={navn}", status_code=303) @app.post("/admin/secrets/{navn}/slet") async def admin_secrets_delete(request: Request, navn: str): require_admin(request) ok = secretdrop.delete(navn) 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/" 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) 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. # Messenger/Facebooks link-forhåndsvisning og deres indbyggede browsere. _FORHAANDSVISNING = re.compile(r"facebookexternalhit|meta-external(agent|fetcher)|Facebot|WhatsApp", re.I) _INDBYGGET = re.compile(r"FBAN|FBAV|FB_IAB|FBIOS|Messenger|Instagram|LinkedInApp", re.I) @app.get("/podcast/f/{key}/", response_class=HTMLResponse) async def podcast_faelles_side(request: Request, key: str): ua = request.headers.get("user-agent", "") forhaandsvisning = bool(_FORHAANDSVISNING.search(ua)) 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) s = udsendelser[0] if udsendelser else {"titel": "Podcast", "slug": "", "har_cover": False} # Et lille omslag (300 px), så Messenger viser en miniature ved teksten i # 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": ("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} headers = {"X-Robots-Tag": "noindex, nofollow, noarchive", "Referrer-Policy": "no-referrer", "Cache-Control": "private, no-store"} if forhaandsvisning: # Forhåndsvisningen får kun titel, en neutral tekst og omslaget, aldrig # episodelisten (den nævner familiemedlemmer ved navn). return templates.TemplateResponse(request, "podcast_og.html", {"request": request, "og": og}, headers=headers) indbygget = None if _INDBYGGET.search(ua): # Messenger på iPhone blokerer også x-safari-https://, så dér er der ingen # knap ud af appen: vejledning til menuen + kopiér feedet til Apple Podcasts. # Android-udgaven af Facebooks browser følger intent://-links. sti = f"{request.url.hostname}{rod}/" if re.search(r"iPhone|iPad|iOS", ua): indbygget = {"platform": "ios"} elif "Android" in ua: indbygget = {"platform": "android", "href": f"intent://{sti}#Intent;scheme=https;end"} else: indbygget = {"platform": "andet"} return templates.TemplateResponse(request, "podcast.html", { "request": request, "user": None, "is_admin": False, "rod": rod, "delt": True, "og": og, "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.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: 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-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.delt_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}/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.delt_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.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"}) @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(), "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()]}) @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/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) 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) # ---------------- erindringer (interview + dagbog) ---------------- # Indloggede (familie) bruger sessionen; gæster uden login bruger en hemmelig nøgle i # adressen og sender den med i API-kald ("noegle"). Begge ender som en "person" i modulet. def _erindringer_person(request: Request, noegle: str = "") -> dict: user = current_user(request) if user and (is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or [])): return erindringer.person_for_bruger(user["username"], user.get("name") or user["username"]) if noegle: if podcast.blokeret(_klient_ip(request)): raise HTTPException(404) p = erindringer.person_for_noegle(noegle) if p: return p podcast.forkert(_klient_ip(request)) raise HTTPException(404) if not user and request.method == "GET": nxt = request.url.path raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")}) raise HTTPException(403, "Kun for familien") def _erindringer_side(request: Request, p: dict, rod: str, noegle: str, saved: str, valgt: str, alle: bool = False): user = current_user(request) pr = [] for slug in p.get("projekter") or []: x = erindringer.projekt(slug) if x: pr.append({**x, "antal": len(erindringer.klip_for(p["pid"], slug))}) titler = {x["slug"]: x["titel"] for x in pr} alle_klip = list(reversed(erindringer.klip_for(p["pid"]))) seneste = alle_klip if alle else alle_klip[:20] for k in seneste: k["projekt_titel"] = titler.get(k.get("projekt"), k.get("projekt")) k["dag"] = (k.get("tid") or "")[:10] return templates.TemplateResponse(request, "erindringer.html", { "request": request, "user": user, "is_admin": is_admin(user), "navn": p.get("navn") or "", "noegle": noegle, "rod": rod, "api": "/erindringer/api", "projekter": pr, "klip_seneste": seneste, "alle": alle, "antal_alle": len(alle_klip), "idag": time.strftime("%Y-%m-%d"), "saved": saved, "valgt": valgt if valgt in titler else ""}) @app.get("/erindringer", response_class=HTMLResponse) async def erindringer_side(request: Request, saved: str = "", valgt: str = ""): p = _erindringer_person(request) return _erindringer_side(request, p, "/erindringer", "", saved, valgt) @app.get("/erindringer/alle", response_class=HTMLResponse) async def erindringer_alle(request: Request): p = _erindringer_person(request) return _erindringer_side(request, p, "/erindringer", "", "", "", alle=True) @app.get("/erindringer/g/{key}/alle/", response_class=HTMLResponse) async def erindringer_gaest_alle(request: Request, key: str): p = _erindringer_person(request, key) r = _erindringer_side(request, p, f"/erindringer/g/{key}", key, "", "", alle=True) r.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive" return r @app.get("/erindringer/g/{key}/", response_class=HTMLResponse) async def erindringer_gaest(request: Request, key: str, saved: str = "", valgt: str = ""): p = _erindringer_person(request, key) r = _erindringer_side(request, p, f"/erindringer/g/{key}", key, saved, valgt) r.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive" r.headers["Referrer-Policy"] = "no-referrer" return r @app.get("/erindringer/tts/{sha}.mp3") async def erindringer_tts(sha: str): p = erindringer.tts_fil(sha) if not p: raise HTTPException(404) return FileResponse(p, media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"}) def _klip_svar(pid: str, kid: str): if kid.endswith("-sp"): # spørgsmålets oplæsning hører til svaret: -sp.mp3 return _spoergsmaal_svar(pid, kid[:-3]) if kid.endswith("-ren"): # svaret med baggrundsstøj fjernet (valgfrit) f = erindringer.renset_fil(pid, kid[:-4]) if not f: raise HTTPException(404) return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, no-store"}) f = erindringer.klip_fil(pid, kid) if not f: raise HTTPException(404) return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, no-store"}) @app.get("/erindringer/klip/{kid}.mp3") async def erindringer_klip(request: Request, kid: str): p = _erindringer_person(request) return _klip_svar(p["pid"], kid) @app.get("/erindringer/g/{key}/klip/{kid}.mp3") async def erindringer_gaest_klip(request: Request, key: str, kid: str): p = _erindringer_person(request, key) return _klip_svar(p["pid"], kid) def _spoergsmaal_svar(pid: str, kid: str): f = erindringer.spoergsmaal_fil(pid, kid) if not f: raise HTTPException(404) return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"}) def _fejl(status: int, tekst: str): return JSONResponse({"fejl": tekst}, status_code=status) @app.post("/erindringer/api/naeste") async def erindringer_naeste(request: Request): try: data = await request.json() except ValueError: return _fejl(400, "Ugyldigt kald.") p = _erindringer_person(request, str(data.get("noegle") or "")) slug = str(data.get("projekt") or "") if not erindringer.maa(p, slug): return _fejl(403, "Det emne er ikke åbent for dig.") pr = erindringer.projekt(slug) samtale = str(data.get("samtale") or "") if slug == "dagbog": samtale = erindringer.dagbog_samtale(str(data.get("dato") or "")) elif not erindringer.SAMTALE.match(samtale): samtale = erindringer.ny_samtale() sprunget = [str(x)[:400] for x in (data.get("sprunget") or []) if isinstance(x, str)][:50] q = await _naeste_med_lyd(p, pr, samtale, sprunget) return {"samtale": samtale, "spoergsmaal": q["spoergsmaal"], "talt": q["talt"], "afslut": q["afslut"], "kilde": q["kilde"], "lyd_url": f"/erindringer/tts/{q['lyd_sha']}.mp3" if q.get("lyd_sha") else None, "lyd_sha": q.get("lyd_sha") or "", "nr": len(erindringer.klip_for(p["pid"], slug, samtale)) + 1} async def _naeste_med_lyd(p: dict, pr: dict, samtale: str, sprunget: list[str]) -> dict: """Næste spørgsmål med oplæsning: tag det forudberegnede, hvis det bygger på det seneste svar; ellers vent (kort) på at det seneste svar er skrevet af, og regn det ud nu.""" seneste = erindringer.klip_for(p["pid"], pr["slug"], samtale) basis = seneste[-1]["id"] if seneste else "" if not sprunget: v = erindringer.hent_forberedt(p["pid"], samtale, basis) if v and v.get("q"): return {**v["q"], "lyd_sha": v.get("lyd_sha") or ""} if seneste and seneste[-1].get("status") == "transskriberes": for _ in range(25): # op til ca. 25 s: teksten er på vej fra STT await asyncio.sleep(1) k = erindringer.klip(p["pid"], basis) if not k or k.get("status") != "transskriberes": break q = await erindringer.naeste_spoergsmaal(p, pr, samtale, sprunget) lyd = await asyncio.to_thread(erindringer.tts_sti, q["talt"]) return {**q, "lyd_sha": lyd.stem if lyd else ""} _baggrund: set = set() # stærke referencer: en create_task uden reference kan blive samlet op af GC midtvejs def _i_baggrunden(coro) -> None: t = asyncio.create_task(coro) _baggrund.add(t) t.add_done_callback(_baggrund.discard) async def _efter_svar(p: dict, pr: dict, samtale: str, kid: str) -> None: """Baggrundsarbejde efter et svar: transskribér, og forbered næste spørgsmål (tekst + lyd), så det ligger klar, når personen trykker 'Næste spørgsmål'.""" try: await asyncio.to_thread(erindringer.transskriber_klip, p["pid"], kid) k = erindringer.klip(p["pid"], kid) if not k or k.get("kladde"): return # ikke sendt endnu (eller slettet): intet at forberede q = await erindringer.naeste_spoergsmaal(p, pr, samtale, []) lyd = await asyncio.to_thread(erindringer.tts_sti, q["talt"]) erindringer.gem_forberedt(p["pid"], samtale, kid, q, lyd.stem if lyd else "") except Exception: # baggrund: må aldrig vælte appen; næste kald regner selv logging.getLogger("portal.erindringer").exception("baggrundsjob efter svar %s fejlede", kid) @app.post("/erindringer/api/svar") async def erindringer_svar(request: Request): form = await request.form() p = _erindringer_person(request, str(form.get("noegle") or "")) slug = str(form.get("projekt") or "") if not erindringer.maa(p, slug): return _fejl(403, "Det emne er ikke åbent for dig.") samtale = str(form.get("samtale") or "") if not erindringer.SAMTALE.match(samtale): samtale = erindringer.dagbog_samtale() if slug == "dagbog" else erindringer.ny_samtale() lyd = form.get("lyd") data, filnavn = None, "" if lyd is not None and hasattr(lyd, "read"): data = await lyd.read() filnavn = getattr(lyd, "filename", "") or "" if not data: data = None tekst = str(form.get("tekst") or "") kladde = str(form.get("kladde") or "") == "1" try: meta = await asyncio.to_thread(erindringer.gem_svar, p["pid"], slug, samtale, str(form.get("spoergsmaal") or ""), data, filnavn, tekst or None, str(form.get("spoergsmaal_talt") or ""), str(form.get("lyd_sha") or ""), str(form.get("kilde") or ""), kladde) except ValueError as e: return _fejl(400, str(e)) _i_baggrunden(_efter_svar(p, erindringer.projekt(slug), samtale, meta["id"])) return {"id": meta["id"], "tekst": meta["tekst"], "status": meta["status"], "varighed": meta["varighed"], "samtale": samtale, "kladde": kladde, "skrevet": meta.get("skrevet", False)} @app.post("/erindringer/api/stt-token") async def erindringer_stt_token(request: Request): try: data = await request.json() except ValueError: data = {} _erindringer_person(request, str(data.get("noegle") or "")) t = await asyncio.to_thread(erindringer.stt_token) if not t: return _fejl(503, "Live-afskrift er ikke tilgængelig lige nu.") return t @app.post("/erindringer/api/status") async def erindringer_status(request: Request): try: data = await request.json() except ValueError: return _fejl(400, "Ugyldigt kald.") p = _erindringer_person(request, str(data.get("noegle") or "")) k = erindringer.klip(p["pid"], str(data.get("id") or "")) if not k: return _fejl(404, "Svaret findes ikke.") return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0), "skrevet": k.get("skrevet", False)} @app.post("/erindringer/api/bekraeft") async def erindringer_bekraeft(request: Request): try: data = await request.json() except ValueError: return _fejl(400, "Ugyldigt kald.") p = _erindringer_person(request, str(data.get("noegle") or "")) k = erindringer.bekraeft(p["pid"], str(data.get("id") or "")) if not k: return _fejl(404, "Svaret findes ikke.") if k.get("status") != "transskriberes": # Teksten er allerede klar: forbered næste spørgsmål nu (ellers gør baggrundsjobbet det) pr = erindringer.projekt(k.get("projekt") or "") if pr: _i_baggrunden(_efter_svar(p, pr, k.get("samtale") or "", k["id"])) return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0), "skrevet": k.get("skrevet", False)} @app.post("/erindringer/api/slet") async def erindringer_slet(request: Request): try: data = await request.json() except ValueError: return _fejl(400, "Ugyldigt kald.") p = _erindringer_person(request, str(data.get("noegle") or "")) if not erindringer.slet_klip(p["pid"], str(data.get("id") or "")): return _fejl(404, "Svaret findes ikke.") return {"ok": True} @app.post("/erindringer/api/ret") async def erindringer_ret(request: Request): try: data = await request.json() except ValueError: return _fejl(400, "Ugyldigt kald.") p = _erindringer_person(request, str(data.get("noegle") or "")) if not erindringer.ret_tekst(p["pid"], str(data.get("id") or ""), str(data.get("tekst") or "")): return _fejl(404, "Svaret findes ikke.") return {"ok": True} @app.get("/admin/erindringer", response_class=HTMLResponse) async def admin_erindringer(request: Request, saved: str = "", ny: str = ""): user = require_admin(request) pr = erindringer.projekter() return templates.TemplateResponse(request, "admin_erindringer.html", { "request": request, "user": user, "is_admin": True, "saved": saved, "ny": ny, "base": str(request.base_url).rstrip("/"), "projekter": [{**v, "slug": k} for k, v in pr.items()], "personer": erindringer.alle_personer(), "har_claude": bool(erindringer.ANTHROPIC_KEY), "har_eleven": bool(erindringer.ELEVEN_KEY), "model": erindringer.MODEL}) @app.post("/admin/erindringer/projekt") async def admin_erindringer_projekt(request: Request): require_admin(request) form = await request.form() slug = re.sub(r"[^a-z0-9-]", "-", str(form.get("slug") or "").strip().lower()).strip("-")[:40] if str(form.get("handling") or "") == "slet": erindringer.slet_projekt(slug) return RedirectResponse("/admin/erindringer?saved=Projektet+er+slettet", status_code=303) start = [s for s in str(form.get("start") or "").splitlines()] try: opfoelg = int(str(form.get("opfoelg") or "2")) except ValueError: opfoelg = 2 if not erindringer.gem_projekt(slug, str(form.get("titel") or ""), str(form.get("beskrivelse") or ""), str(form.get("kontekst") or ""), start, opfoelg): return RedirectResponse("/admin/erindringer?saved=Projektet+skal+have+et+navn+(a-z,+0-9,+bindestreg)+og+en+titel", status_code=303) return RedirectResponse(f"/admin/erindringer?saved=Projektet+{urllib.parse.quote(slug)}+er+gemt", status_code=303) @app.post("/admin/erindringer/gaest") async def admin_erindringer_gaest(request: Request): require_admin(request) form = await request.form() navn = str(form.get("navn") or "").strip()[:80] if not navn: return RedirectResponse("/admin/erindringer?saved=Skriv+et+navn", status_code=303) p = erindringer.ny_gaest(navn, [str(x) for x in form.getlist("projekter")]) return RedirectResponse(f"/admin/erindringer?ny={urllib.parse.quote(p['pid'])}", status_code=303) @app.post("/admin/erindringer/person/{pid}") async def admin_erindringer_person_save(request: Request, pid: str): require_admin(request) form = await request.form() handling = str(form.get("handling") or "") if handling == "projekter": erindringer.saet_projekter(pid, [str(x) for x in form.getlist("projekter")]) besked = "Emnerne er gemt" elif handling == "ny-noegle": erindringer.ny_noegle(pid); besked = "Nyt link lavet; det gamle virker ikke mere" elif handling == "luk": erindringer.luk_gaest(pid, True); besked = "Linket er lukket" elif handling == "aabn": erindringer.luk_gaest(pid, False); besked = "Linket er åbnet igen" else: besked = "Ukendt handling" return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}?saved={urllib.parse.quote(besked)}", status_code=303) @app.get("/admin/erindringer/p/{pid}", response_class=HTMLResponse) async def admin_erindringer_person(request: Request, pid: str, saved: str = ""): user = require_admin(request) p = erindringer.person(pid) if not p: raise HTTPException(404) pr = erindringer.projekter() k = list(reversed(erindringer.klip_for(pid))) for x in k: x["projekt_titel"] = (pr.get(x.get("projekt")) or {}).get("titel", x.get("projekt")) return templates.TemplateResponse(request, "admin_erindringer_person.html", { "request": request, "user": user, "is_admin": True, "saved": saved, "p": p, "klip": k, "base": str(request.base_url).rstrip("/"), "projekter": [{**v, "slug": s} for s, v in pr.items()]}) @app.get("/admin/erindringer/p/{pid}/klip/{kid}.mp3") async def admin_erindringer_klip(request: Request, pid: str, kid: str): require_admin(request) return _klip_svar(pid, kid) @app.post("/admin/erindringer/p/{pid}/klip/{kid}") async def admin_erindringer_klip_post(request: Request, pid: str, kid: str): require_admin(request) form = await request.form() handling = str(form.get("handling") or "") if handling == "favorit": erindringer.saet_favorit(pid, kid, str(form.get("favorit") or "") == "1") elif handling == "slet": erindringer.slet_klip(pid, kid) elif handling == "ret": erindringer.ret_tekst(pid, kid, str(form.get("tekst") or "")) elif handling == "isoler": ok = await asyncio.to_thread(erindringer.isoler_stemme, pid, kid) if not ok: return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}?saved=Rensningen+lykkedes+ikke#k-{kid}", status_code=303) return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}#k-{kid}", status_code=303) @app.get("/admin/erindringer/p/{pid}/eksport.zip") async def admin_erindringer_eksport(request: Request, pid: str): require_admin(request) if not erindringer.person(pid): raise HTTPException(404) data = await asyncio.to_thread(erindringer.eksport, pid) navn = pid.replace(":", "_") return Response(data, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="erindringer-{navn}.zip"'}) @app.get("/admin/claude-login", response_class=HTMLResponse) async def admin_claude_login(request: Request): user = require_admin(request) return templates.TemplateResponse(request, "admin_claude_login.html", { "request": request, "user": user, "is_admin": True, "st": claudelogin.state()}) @app.get("/admin/claude-login/status") async def admin_claude_login_status(request: Request): require_admin(request) return JSONResponse(claudelogin.state(), headers={"Cache-Control": "no-store"}) @app.post("/admin/claude-login/start") async def admin_claude_login_start(request: Request): user = require_admin(request) claudelogin.request((user or {}).get("username") or "?") return JSONResponse(claudelogin.state()) @app.post("/admin/claude-login/kode") async def admin_claude_login_code(request: Request): require_admin(request) f = await request.form() fejl = claudelogin.submit_code(f.get("kode") or "") return JSONResponse({"error": fejl, **claudelogin.state()}, status_code=400 if fejl else 200) @app.get("/admin/groups", response_class=HTMLResponse) async def admin_groups(request: Request, saved: str = ""): user = require_admin(request) return templates.TemplateResponse(request, "admin_groups.html", { "request": request, "user": user, "is_admin": True, "groups": await ak.list_groups(), "saved": saved}) @app.post("/admin/groups") async def admin_groups_create(request: Request): require_admin(request) f = await request.form() name = (f.get("name") or "").strip() if name: await ak.create_group(name) cache.clear("groups") return RedirectResponse(f"/admin/groups?saved=Gruppe '{name}' oprettet", status_code=303) @app.get("/admin/groups/{gpk}", response_class=HTMLResponse) async def admin_group_edit(request: Request, gpk: str): user = require_admin(request) return templates.TemplateResponse(request, "admin_group_edit.html", { "request": request, "user": user, "is_admin": True, "g": await ak.group_members(gpk), "users": await ak.list_users()}) @app.post("/admin/groups/{gpk}") async def admin_group_save(request: Request, gpk: str): user = require_admin(request) f = await request.form() members = f.getlist("members") # Guardrail: du kan ikke fjerne dig selv fra admin-gruppen (andre kan godt). me = await ak.user_by_username(user["username"]) admin_pk = await ak.admin_group_pk(config.ADMIN_GROUP) note = "" if me and str(gpk) == str(admin_pk) and admin_pk in me["groups"] and me["pk"] not in members: members = members + [me["pk"]] note = " (du kan ikke fjerne dig selv fra admin-gruppen — beholdt)" await ak.set_group_members(gpk, members) cache.clear("groups", "users", "apps", "access") return RedirectResponse(f"/admin/groups?saved=Medlemmer gemt{note}", status_code=303) # ---------------- adgang til services (per-app policy-bindings) ---------------- @app.get("/admin/access", response_class=HTMLResponse) async def admin_access(request: Request, saved: str = ""): user = require_admin(request) return templates.TemplateResponse(request, "admin_access.html", { "request": request, "user": user, "is_admin": True, "apps": await ak.list_apps_access(), "saved": saved}) @app.get("/admin/access/{slug}", response_class=HTMLResponse) async def admin_access_edit(request: Request, slug: str): user = require_admin(request) app_ = await ak.app_access(slug) if not app_: raise HTTPException(404, "Ukendt app") admin_pk = await ak.admin_group_pk(config.ADMIN_GROUP) me = await ak.user_by_username(user["username"]) # "Følsom" udgangspunkt = adgang i dag KUN for admin-gruppen, eller KUN for den # indloggede selv → udvidelse skal bekræftes (rød modal). only_admins = app_["groups"] == {str(admin_pk)} and not app_["users"] only_me = bool(me) and app_["users"] == {me["pk"]} and not app_["groups"] sensitive = only_admins or only_me return templates.TemplateResponse(request, "admin_access_edit.html", { "request": request, "user": user, "is_admin": True, "app": app_, "groups": await ak.list_groups(), "users": await ak.list_users(), "admin_pk": str(admin_pk), "sensitive": sensitive, "orig_groups": sorted(app_["groups"]), "orig_users": sorted(app_["users"])}) @app.post("/admin/access/{slug}") async def admin_access_save(request: Request, slug: str): user = require_admin(request) app_ = await ak.app_access(slug) if not app_: raise HTTPException(404, "Ukendt app") f = await request.form() want_groups = set(f.getlist("groups")) want_users = set(f.getlist("users")) # Guardrail: du må ikke fjerne din egen adgang til en service. me = await ak.user_by_username(user["username"]) if (want_groups or want_users) and me: covered = bool(want_groups & me["groups"]) or me["pk"] in want_users if not covered: return RedirectResponse( f"/admin/access/{slug}?saved=Afvist: du ville fjerne din egen adgang til '{app_['name']}'", status_code=303) added, removed = await ak.set_app_access(app_["pk"], want_groups, want_users) cache.clear("access", "apps") parts = [] if added: parts.append(f"{added} tilføjet") if removed: parts.append(f"{removed} fjernet") detail = " · ".join(parts) if parts else "ingen ændringer" return RedirectResponse(f"/admin/access?saved=Adgang til '{app_['name']}' gemt — {detail}", status_code=303) # ---------------- e-pub (ebog/lydbog) ---------------- def require_epub(request: Request): """Logget ind + admin eller medlem af indstillingens allowed_group. Uden login: login og tilbage.""" 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 epub.har_adgang(user, is_admin(user)): raise HTTPException(403, "Du har ikke adgang til denne side") return user def _epub_job(request: Request, jid: str) -> dict: user = require_epub(request) j = epub.job(jid) if not j or (j["ejer"] != user["username"] and not is_admin(user)): raise HTTPException(404, "Ukendt job") return j @app.get("/e-pub", response_class=HTMLResponse) async def epub_side(request: Request, saved: str = ""): user = require_epub(request) return templates.TemplateResponse(request, "epub.html", { "request": request, "user": user, "is_admin": is_admin(user), "saved": saved, "jobs": [epub.status(j) for j in epub.brugerens_jobs(user["username"])], "ttl": epub.varighed(int(epub.get_settings()["output_ttl_seconds"]))}) @app.post("/e-pub/start") async def epub_start(request: Request): user = require_epub(request) vil_json = "application/json" in request.headers.get("accept", "") try: laengde = int(request.headers.get("content-length") or 0) except ValueError: laengde = 0 if laengde > 16384: raise HTTPException(413, "For stor forespørgsel") f = await request.form() tekst = str(f.get("link") or "").strip() fejl = epub.valider_input(tekst) j = None if not fejl: j, fejl = epub.opret(user["username"], tekst) if fejl: if vil_json: return JSONResponse({"error": fejl}, status_code=400) return RedirectResponse("/e-pub?saved=" + urllib.parse.quote(fejl), status_code=303) _i_baggrunden(epub.koer(j["id"])) if vil_json: return JSONResponse(epub.status(j)) return RedirectResponse("/e-pub?saved=" + urllib.parse.quote("Startet"), status_code=303) @app.get("/e-pub/status/{jid}") async def epub_status(request: Request, jid: str): return JSONResponse(epub.status(_epub_job(request, jid)), headers={"Cache-Control": "no-store"}) @app.get("/e-pub/hent/{jid}") async def epub_hent(request: Request, jid: str): j = _epub_job(request, jid) if j["tilstand"] != "faerdig" or not j["fil"]: raise HTTPException(410, "Filen er her ikke længere") # Én hentning: filen og jobmappen slettes, når svaret er sendt (ellers ved TTL). return FileResponse(j["fil"], filename=j["filnavn"], media_type=epub.content_type(j["filnavn"]), headers={"Cache-Control": "no-store"}, background=BackgroundTask(epub.ryd, j)) @app.post("/e-pub/send/{jid}") async def epub_send(request: Request, jid: str): j = _epub_job(request, jid) fejl = await asyncio.to_thread(epub.send, j) return JSONResponse({"error": fejl, **epub.status(j)}, status_code=400 if fejl else 200) @app.get("/admin/epub", response_class=HTMLResponse) async def admin_epub(request: Request, saved: str = ""): user = require_admin(request) s = epub.get_settings() return templates.TemplateResponse(request, "admin_epub.html", { "request": request, "user": user, "is_admin": True, "saved": saved, "settings": s, "tjek": epub.tjek_opsaetning(s), "jobs": [{**epub.status(j), "ejer": j["ejer"]} for j in epub.alle_jobs()], "human": epub.human}) @app.post("/admin/epub") async def admin_epub_save(request: Request): require_admin(request) f = await request.form() def num(name, lo, hi): try: return max(lo, min(hi, int(f.get(name)))) except (TypeError, ValueError): return config.EPUB_DEFAULTS[name] tools_dir = str(f.get("tools_dir") or "").strip() mapper = [str(f.get(k) or "").strip() for k in ("cwa_ingest_dir", "abs_dir")] if not tools_dir.startswith("/") or any(m and not m.startswith("/") for m in mapper): return RedirectResponse("/admin/epub?saved=" + urllib.parse.quote("Afvist: mapperne skal være absolutte stier"), status_code=303) epub.save_settings({ "max_concurrent_jobs": num("max_concurrent_jobs", 1, 8), "job_timeout_seconds": num("job_timeout_seconds", 60, 3600), "output_ttl_seconds": num("output_ttl_seconds", 60, 7 * 86400), "tools_dir": tools_dir.rstrip("/") or "/", "rp_required": f.get("rp_required") == "on", "auto_send": f.get("auto_send") == "on", "allowed_group": str(f.get("allowed_group") or "").strip()[:150], "cwa_ingest_dir": str(f.get("cwa_ingest_dir") or "").strip().rstrip("/"), "abs_dir": str(f.get("abs_dir") or "").strip().rstrip("/"), }) return RedirectResponse("/admin/epub?saved=Indstillinger gemt", status_code=303) # ---------------- medie-oprydning ---------------- @app.get("/admin/media", response_class=HTMLResponse) async def admin_media(request: Request, saved: str = ""): user = require_admin(request) ctx = {"request": request, "user": user, "is_admin": True, "saved": saved, "settings": mc.get_settings(), "human": mc._human} if not mc.configured(): ctx["unconfigured"] = True return templates.TemplateResponse(request, "admin_media.html", ctx) inv = cache.get("media:inv") if inv is None: inv = await mc.inventory(ctx["settings"]) cache.put("media:inv", inv, 120) st = mc.get_state() ctx.update({"unconfigured": False, "items": inv["items"], "watch_ok": inv["watch_ok"], "collections": inv.get("collections", []), "leaving": st["leaving"], "log": st["log"][:15], "disk": await mc.disk_usage(), "now": time.time()}) return templates.TemplateResponse(request, "admin_media.html", ctx) @app.post("/api/media/keep") async def api_media_keep(request: Request): require_admin(request) body = await request.json() key, on = str(body.get("key") or ""), bool(body.get("on")) kind, _, sid = key.partition(":") if kind not in ("movie", "series") or not sid.isdigit(): raise HTTPException(400, "Ugyldig nøgle") st = mc.get_state() if on: st["keep"][key] = True st["leaving"].pop(key, None) else: st["keep"].pop(key, None) mc.save_state(st) tag_ok, err = True, None try: await mc.set_keep_tag(kind, int(sid), on) except Exception as e: tag_ok, err = False, str(e)[:120] cache.clear("media:") return {"ok": True, "key": key, "on": on, "tag_ok": tag_ok, "error": err} @app.post("/api/media/collection-keep") async def api_media_collection_keep(request: Request): require_admin(request) body = await request.json() tmdb, on = str(body.get("tmdb") or ""), bool(body.get("on")) title = str(body.get("title") or "") if not tmdb.isdigit(): raise HTTPException(400, "Ugyldig samling") st = mc.get_state() if on: st["keep_collections"][tmdb] = title else: st["keep_collections"].pop(tmdb, None) mc.save_state(st) cache.clear("media:") return {"ok": True, "tmdb": tmdb, "on": on} @app.post("/admin/media/run") async def admin_media_run(request: Request): require_admin(request) inv = await mc.inventory(mc.get_settings()) cache.put("media:inv", inv, 120) if not inv["watch_ok"]: return RedirectResponse("/admin/media?saved=Emby utilgængelig — kunne ikke beregne", status_code=303) cands = [i for i in inv["items"] if i["status"] == "candidate"] total = sum(i["size"] for i in cands) s = mc.get_settings() mode = "sletning AKTIV" if s["enabled"] else "dry-run (sletning slået fra)" return RedirectResponse( f"/admin/media?saved=Simulering: {len(cands)} kandidater · frigør {mc._human(total)} · {mode}", status_code=303) @app.post("/admin/media/settings") async def admin_media_settings(request: Request): require_admin(request) f = await request.form() def num(name, default, lo, hi): try: return max(lo, min(hi, int(f.get(name)))) except (TypeError, ValueError): return default mc.save_settings({ "enabled": f.get("enabled") == "on", "do_movies": f.get("do_movies") == "on", "do_series": f.get("do_series") == "on", "disk_throttle": f.get("disk_throttle") == "on", "age_days": num("age_days", 90, 1, 3650), "grace_days": num("grace_days", 14, 0, 365), "disk_high": num("disk_high", 85, 5, 100), "disk_low": num("disk_low", 80, 1, 99), }) cache.clear("media:") return RedirectResponse("/admin/media?saved=Indstillinger gemt", status_code=303) # ---------------- api ---------------- @app.get("/api/settings") async def api_settings(): return JSONResponse(store.load_json(config.SETTINGS_PATH, {})) @app.post("/api/favorite") async def api_favorite(request: Request): user = current_user(request) if not user: raise HTTPException(401) body = await request.json() favs = set(store.get_prefs(user["username"])["favorites"]) slug = str(body.get("slug") or "") favs.add(slug) if body.get("on") else favs.discard(slug) store.set_prefs(user["username"], favorites=sorted(favs)) return {"ok": True} @app.post("/api/view") async def api_view(request: Request): user = current_user(request) if not user: raise HTTPException(401) v = (await request.json()).get("view") if v in ("grouped", "alpha"): store.set_prefs(user["username"], view=v) return {"ok": True} @app.get("/api/search-index") async def search_index(request: Request): user = current_user(request) if not user: return JSONResponse([]) items = [{"type": "page", "title": "Forside", "url": "/"}] if is_admin(user): items += [ {"type": "page", "title": "Admin-panel", "url": "/admin", "extra": "admin"}, {"type": "page", "title": "Brugere", "url": "/admin/users", "extra": "users brugere"}, {"type": "page", "title": "Grupper", "url": "/admin/groups", "extra": "groups grupper"}, {"type": "page", "title": "Adgang til services", "url": "/admin/access", "extra": "access adgang bindings rettigheder"}, {"type": "page", "title": "Drift / egress", "url": "/admin/ops", "extra": "proxy version"}, {"type": "page", "title": "App-beskrivelser", "url": "/admin/apps", "extra": "apps"}, {"type": "page", "title": "IP → label & land", "url": "/admin/ip-labels", "extra": "ip geo"}, {"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo"}, {"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"}, {"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"}, {"type": "page", "title": "MCP-nøgler", "url": "/admin/mcp-keys", "extra": "mcp api nøgler token claude"}] if epub.har_adgang(user, is_admin(user)): items.append({"type": "page", "title": "E-bog / lydbog", "url": "/e-pub", "extra": "epub m4b bog lydbog bibliotek"}) try: for a in await ak.fetch_user_apps(user["username"]): items.append({"type": "app", "title": a["name"], "url": a["url"], "extra": f'{a["desc"]} {a["host"]}'.strip()}) except Exception: pass return JSONResponse(items) @app.get("/healthz") async def healthz(): return {"ok": True, "configured": bool(config.CLIENT_ID and config.API_TOKEN)} # ---------------- MCP-nøgler (central registry) ---------------- @app.get("/admin/mcp-keys", response_class=HTMLResponse) async def admin_mcp_keys(request: Request, saved: str = ""): user = require_admin(request) external = [{**e, "command": mcpkeys.build_command( mcp_id=e["id"], transport=e.get("transport", "http"), url=e.get("url"), token=e.get("token"), header=e.get("auth_header", "Authorization"), command=e.get("command"))} for e in mcpkeys.list_external()] portal_tokens = mcpkeys.list_portal_tokens() new_token = request.session.pop("mcp_new", None) oauth_access = mcpkeys.get_oauth_access() configured = oauth_access.get("users") or {} # Service accounts (outposts etc.) are not people and would only clutter the # picker. If Authentik is unreachable the page still renders — the rows fall # back to the bare username. try: people = [u for u in await ak.list_users() if not u["username"].startswith("ak-outpost-") and (u.get("type") or "internal") != "service_account"] except Exception: people = [] by_name = {u["username"]: u for u in people} def _label(username): u = by_name.get(username) full = (u or {}).get("name") or "" return f"{full} ({username})" if full else username oauth_rows = [{"username": u, "allowed": v, "label": _label(u)} for u, v in sorted(configured.items())] ak_users_available = sorted( ({"username": u["username"], "label": _label(u["username"])} for u in people if u["username"] not in configured), key=lambda x: x["label"].lower()) return templates.TemplateResponse(request, "admin_mcp_keys.html", { "request": request, "user": user, "is_admin": True, "external": external, "portal_tokens": portal_tokens, "self_url": mcpkeys.SELF_URL, "saved": saved, "new_token": new_token, "namespaces": mcpkeys.namespaces(), "oauth_access": oauth_access, "oauth_rows": oauth_rows, "ak_users_available": ak_users_available}) @app.post("/admin/mcp-keys") async def admin_mcp_keys_post(request: Request): require_admin(request) form = await request.form() action = form.get("action", "") if action == "save_external": eid = (form.get("id") or "").strip() if eid: mcpkeys.upsert_external({ "id": eid, "label": (form.get("label") or eid).strip(), "transport": (form.get("transport") or "http").strip(), "url": (form.get("url") or "").strip(), "auth_header": (form.get("auth_header") or "Authorization").strip(), "token": (form.get("token") or "").strip(), "command": (form.get("command") or "").strip(), "notes": (form.get("notes") or "").strip()}) return RedirectResponse("/admin/mcp-keys?saved=Gemt", status_code=303) if action == "delete_external": mcpkeys.delete_external((form.get("id") or "").strip()) return RedirectResponse("/admin/mcp-keys?saved=Slettet", status_code=303) if action == "set_allowed": mcpkeys.set_token_allowed((form.get("name") or "").strip(), form.getlist("allowed")) return RedirectResponse("/admin/mcp-keys?saved=Adgang gemt", status_code=303) if action == "set_oauth_default": mcpkeys.set_oauth_default(form.getlist("default")) return RedirectResponse("/admin/mcp-keys?saved=Standard gemt", status_code=303) if action == "set_oauth_user": who = (form.get("name") or "").strip() if who: mcpkeys.set_oauth_user(who, form.getlist("allowed")) return RedirectResponse("/admin/mcp-keys?saved=Adgang gemt", status_code=303) if action == "del_oauth_user": mcpkeys.del_oauth_user((form.get("name") or "").strip()) return RedirectResponse("/admin/mcp-keys?saved=Fjernet", status_code=303) if action == "mint_portal": name = (form.get("name") or "").strip() or "ny-nøgle" tok = mcpkeys.mint_portal_token(name, form.getlist("allowed")) cmd = mcpkeys.build_command(mcp_id="weircon", transport="http", url=mcpkeys.SELF_URL, token=tok) request.session["mcp_new"] = {"name": name, "token": tok, "command": cmd} return RedirectResponse("/admin/mcp-keys?saved=Token oprettet — kopiér det nu, det vises kun én gang", status_code=303) if action == "revoke_portal": mcpkeys.revoke_portal_token((form.get("name") or "").strip()) return RedirectResponse("/admin/mcp-keys?saved=Token spærret", status_code=303) if action == "healthcheck": eid = (form.get("id") or "").strip() for e in mcpkeys.list_external(): if e["id"] == eid: e["last_check"] = await mcpkeys.health_check(e) mcpkeys.upsert_external(e) return RedirectResponse("/admin/mcp-keys?saved=Tjekket", status_code=303) return RedirectResponse("/admin/mcp-keys", status_code=303) # ---------------- YouTube offline ---------------- @app.get("/admin/yt", response_class=HTMLResponse) async def admin_yt(request: Request, saved: str = ""): user = require_admin(request) st = yt.get_state() jobs = sorted(st["jobs"].values(), key=lambda j: j["added"], reverse=True) ctx = {"request": request, "user": user, "is_admin": True, "saved": saved, "settings": yt.get_settings(), "human": yt._human, "now": time.time(), "download_dir": config.YT_DOWNLOAD_DIR, "unconfigured": False, "ours": [], "ours_size": 0, "leaving": st["leaving"], "log": st["log"][:15], "jobs": jobs[:25], "inv_error": "", "subs": [], "queued": sum(1 for j in jobs if j["status"] == "queued"), "running": any(j["status"] == "running" for j in jobs)} if not yt.configured(): ctx["unconfigured"] = True return templates.TemplateResponse(request, "admin_yt.html", ctx) inv = cache.get("yt:inv") if inv is None: inv = await yt.emby_yt_items() cache.put("yt:inv", inv, 60) ctx["inv_error"] = inv.get("error") or "" owned = yt.owned_paths() ours = [{**it, "size": yt.file_size(it["path"]), "delete_after": (st["leaving"].get(it["path"]) or {}).get("delete_after")} for it in inv.get("items", []) if it["path"] in owned] ours.sort(key=lambda v: v["name"].lower()) ctx["ours"] = ours ctx["ours_size"] = sum(v["size"] for v in ours) favs = {it["path"] for it in inv.get("items", []) if it["favorite"]} subs = [] for sb in st["subs"].values(): files = yt.sub_files(st, sb["id"]) subs.append({**sb, "on_disk": len(files), "in_cycle": sum(1 for f in files if f["path"] not in favs), "favorites": sum(1 for f in files if f["path"] in favs)}) subs.sort(key=lambda x: (x["kind"], x["title"].lower())) ctx["subs"] = subs return templates.TemplateResponse(request, "admin_yt.html", ctx) async def _yt_drain(): """Tøm download-køen i baggrunden, så web-svaret ikke venter på yt-dlp. Timeren tager den igen hvis portalen genstarter midt i en hentning.""" try: await yt.process_queue() cache.clear("yt:") except Exception: pass @app.post("/admin/yt/add") async def admin_yt_add(request: Request, url: str = Form(""), playlist: str = Form("")): require_admin(request) url = (url or "").strip() if not url.startswith(("http://", "https://")): return RedirectResponse("/admin/yt?saved=Afvist: det ligner ikke en URL", status_code=303) yt.add_job(url, playlist=bool(playlist)) asyncio.create_task(_yt_drain()) return RedirectResponse("/admin/yt?saved=Lagt i kø — hentningen er i gang", status_code=303) @app.post("/admin/yt/igen") async def admin_yt_retry(request: Request): """Prøv ét fejlet job igen (jid) eller alle fejlede (uden jid).""" require_admin(request) f = await request.form() jid = f.get("jid") or "" if jid: ids = [jid] else: ids = [j["id"] for j in yt.get_state()["jobs"].values() if j["status"] == "error"] n = sum(1 for i in ids if yt.retry_job(i)) if n: asyncio.create_task(_yt_drain()) msg = f"Prøver {n} igen" if n else "Intet at prøve igen" return RedirectResponse(f"/admin/yt?saved={urllib.parse.quote(msg)}", status_code=303) @app.post("/admin/yt/slet") async def admin_yt_delete(request: Request): require_admin(request) f = await request.form() res = yt.delete_video(f.get("path") or "") if not res["ok"]: return RedirectResponse(f"/admin/yt?saved={urllib.parse.quote(res['error'])}", status_code=303) cache.clear("yt:") await yt.refresh_library() return RedirectResponse( "/admin/yt?saved=" + urllib.parse.quote(f"Slettet: {res['title']} ({yt._human(res['size'])})"), status_code=303) @app.post("/admin/yt/settings") async def admin_yt_settings(request: Request): require_admin(request) f = await request.form() patch = {k: bool(f.get(k)) for k in ("enabled", "sponsorblock", "subtitles", "metadata")} try: patch["grace_days"] = max(0, min(365, int(f.get("grace_days") or 7))) except ValueError: pass for k in ("sub_langs", "library_name", "quality"): if f.get(k): patch[k] = str(f.get(k)).strip() yt.save_settings(patch) cache.clear("yt:") return RedirectResponse("/admin/yt?saved=Indstillinger gemt", status_code=303) @app.post("/admin/yt/run") async def admin_yt_run(request: Request): require_admin(request) res = await yt.run_cycle(dry_run=False) cache.clear("yt:") if res.get("error"): return RedirectResponse(f"/admin/yt?saved=Fejl: {res['error']}", status_code=303) msg = (f"Kørt — {res['seen']} af vores videoer · {len(res['new'])} nye på vej ud · " f"{len(res['spared'])} fredet · {len(res['deleted'])} slettet") return RedirectResponse(f"/admin/yt?saved={msg}", status_code=303) @app.post("/admin/yt/sub/add") async def admin_yt_sub_add(request: Request, url: str = Form(""), kind: str = Form(""), cycle: str = Form("")): require_admin(request) url = (url or "").strip() if not url.startswith(("http://", "https://")): return RedirectResponse("/admin/yt?saved=Afvist: det ligner ikke en URL", status_code=303) kind = kind if kind in ("playlist", "channel") else yt.sub_kind_from_url(url) try: n = int(cycle or 0) except ValueError: n = 0 if kind == "channel" and n < 1: return RedirectResponse( "/admin/yt?saved=Afvist: en kanal skal have et cycle-tal (hvor mange nye videoer du vil have liggende)", status_code=303) sub = await yt.add_sub(url, kind=kind, cycle=n if kind == "channel" else 0) asyncio.create_task(_yt_subs_run()) cache.clear("yt:") return RedirectResponse( f"/admin/yt?saved=Overvåger nu {sub['title']} — henter i baggrunden", status_code=303) @app.post("/admin/yt/sub/{sid}/save") async def admin_yt_sub_save(request: Request, sid: str, cycle: str = Form(""), enabled: str = Form("")): require_admin(request) patch = {"enabled": bool(enabled)} try: if cycle != "": patch["cycle"] = max(0, int(cycle)) except ValueError: pass yt.set_sub(sid, **patch) cache.clear("yt:") return RedirectResponse("/admin/yt?saved=Abonnement gemt", status_code=303) @app.post("/admin/yt/sub/{sid}/del") async def admin_yt_sub_del(request: Request, sid: str): require_admin(request) yt.del_sub(sid) cache.clear("yt:") return RedirectResponse( "/admin/yt?saved=Abonnement fjernet — allerede hentede videoer bliver liggende", status_code=303) async def _yt_subs_run(): """Tjek abonnementer, hent det nye, og hold kanalernes cyklus.""" try: await yt.sync_subs() await yt.process_queue() await yt.enforce_cycles() cache.clear("yt:") except Exception: pass @app.post("/admin/yt/subs/check") async def admin_yt_subs_check(request: Request): require_admin(request) res = await yt.sync_subs() asyncio.create_task(_yt_subs_run()) cache.clear("yt:") msg = f"Tjekkede {res['checked']} abonnementer — {res['queued']} nye videoer i kø" if res["errors"]: msg += f" · {len(res['errors'])} fejl: {res['errors'][0][:80]}" return RedirectResponse(f"/admin/yt?saved={msg}", status_code=303)