524 lines
21 KiB
Python
524 lines
21 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
|
|
|
|
app = FastAPI(title="Weircon Portal")
|
|
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)
|
|
|
|
|
|
@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"}]
|
|
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)}
|