Udrullet direkte til prod 2026-09-29; importeret her så repo == prod. Bruger Authentik-flowet weircon-invitation (grupper og engangs via fixed_data og politikker i flowet). Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
851 lines
36 KiB
Python
851 lines
36 KiB
Python
"""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 fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
import time
|
|
|
|
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
|
|
|
|
# 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)
|
|
app = FastAPI(title="Weircon Portal", lifespan=mcp_app.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"))
|
|
|
|
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 is_admin(user):
|
|
raise HTTPException(403, "Kun for administratorer")
|
|
return user
|
|
|
|
|
|
# ---------------- auth ----------------
|
|
@app.get("/auth/login")
|
|
async def login(request: Request):
|
|
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("/", 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)
|
|
import asyncio
|
|
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})
|
|
|
|
|
|
# ---------------- 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)
|
|
import asyncio
|
|
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})
|
|
|
|
|
|
@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/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)
|
|
|
|
|
|
# ---------------- 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"}]
|
|
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/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)
|