refactor: split main.py i moduler (config/cache/store/services/authentik) + async dashboard (TTFB ~6ms, indhold via /dashboard-content) + TTL-cache på Authentik-læsninger

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agw
2026-06-11 21:50:00 +02:00
co-authored by Claude Opus 4.8
parent 19386698b5
commit 742d08c9fb
8 changed files with 509 additions and 495 deletions
+104 -440
View File
@@ -1,78 +1,30 @@
"""Weircon Portal — forside (SSO-dashboard) + udvidbar admin.
- Login via Authentik (OIDC). Dashboard viser de apps brugeren har adgang til.
- Admin (kun for medlemmer af ADMIN_GROUP): IP→label-mapping, cookie-tekster, statistik.
- Admin-moduler er registreret i ADMIN_MODULES — tilføj nye ved at registrere en post.
"""
import asyncio
import json
import os
import subprocess
from pathlib import Path
import httpx
"""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, RedirectResponse, JSONResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
BASE = Path(__file__).resolve().parent
DATA = Path(os.environ.get("PORTAL_DATA", "/opt/portal/data"))
DATA.mkdir(parents=True, exist_ok=True)
AUTHENTIK = os.environ["AUTHENTIK_BASE"].rstrip("/")
CLIENT_ID = os.environ["OIDC_CLIENT_ID"]
CLIENT_SECRET = os.environ["OIDC_CLIENT_SECRET"]
API_TOKEN = os.environ["AUTHENTIK_API_TOKEN"]
SECRET_KEY = os.environ["SESSION_SECRET"]
REDIRECT_URI = os.environ.get("OIDC_REDIRECT_URI", "https://weircon.dk/auth/callback")
ADMIN_GROUP = os.environ.get("ADMIN_GROUP", "authentik Admins")
MATOMO_BASE = os.environ.get("MATOMO_BASE", "https://matomo.weircon.dk").rstrip("/")
MATOMO_TOKEN = os.environ.get("MATOMO_TOKEN", "")
MATOMO_SITE = os.environ.get("MATOMO_SITE", "2")
# Sync af IP-labels til NPMplus (CT101) efter hver gemning
IP_LABELS_LOCAL = DATA / "ip_labels.json"
IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "") # fx root@192.168.10.201:/opt/npmplus/custom_nginx/ip_labels.json
SETTINGS_PATH = DATA / "settings.json"
RP_BASE_URL = os.environ.get("RP_BASE_URL", "https://rp.weircon.dk").rstrip("/")
RP_API_KEY = os.environ.get("RP_API_KEY", "")
ASSETS_VERSION_URL = os.environ.get("ASSETS_VERSION_URL", "https://assets.weircon.dk/VERSION")
GEOIP_DB = os.environ.get("GEOIP_DB", str(DATA / "dbip-country.mmdb"))
_geoip_reader = None
import authentik as ak
import cache
import config
import services as svc
import store
app = FastAPI(title="Weircon Portal")
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, https_only=True, same_site="lax", max_age=60 * 60 * 12)
app.mount("/static", StaticFiles(directory=BASE / "static"), name="static")
templates = Jinja2Templates(directory=str(BASE / "templates"))
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"{AUTHENTIK}/application/o/weircon-portal/.well-known/openid-configuration",
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
client_kwargs={"scope": "openid email profile groups"},
)
# ---- udvidbare admin-moduler: tilføj en post for at få en ny admin-side ----
ADMIN_MODULES = [
{"key": "ip-labels", "title": "IP → label & land", "icon": "🌍",
"desc": "Mærk kendte IP-adresser (netværk/land) i statistikken."},
{"key": "cookies", "title": "Cookie-tekster", "icon": "🍪",
"desc": "Teksten på cookie-banneret og /cookies-siden."},
{"key": "stats", "title": "Statistik", "icon": "📊",
"desc": "Hurtigt overblik fra Matomo."},
{"key": "apps", "title": "App-beskrivelser & grupper", "icon": "🧩",
"desc": "Rediger beskrivelse og gruppe på dine Authentik-apps."},
{"key": "ops", "title": "Drift / egress", "icon": "🛰️",
"desc": "Static-assets-version + random-proxy egress, rollover & inflight."},
{"key": "users", "title": "Brugere", "icon": "👤",
"desc": "Opret og administrér brugere + adgang (via grupper)."},
{"key": "groups", "title": "Grupper", "icon": "👥",
"desc": "Grupper og medlemmer — styrer hvilke services brugere har adgang til."},
]
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):
@@ -80,267 +32,20 @@ def current_user(request: Request):
def is_admin(user) -> bool:
return bool(user) and ADMIN_GROUP in (user.get("groups") or [])
return bool(user) and config.ADMIN_GROUP in (user.get("groups") or [])
def load_json(path: Path, default):
try:
return json.loads(path.read_text("utf-8"))
except Exception:
return default
def require_admin(request: Request):
user = current_user(request)
if not is_admin(user):
raise HTTPException(403, "Kun for administratorer")
return user
def save_ip_labels(data: dict) -> str:
IP_LABELS_LOCAL.write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8")
if IP_LABELS_REMOTE:
try:
subprocess.run(
["scp", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=8",
str(IP_LABELS_LOCAL), IP_LABELS_REMOTE],
check=True, capture_output=True, timeout=20)
return "gemt + synkroniseret til NPMplus"
except Exception as e:
return f"gemt lokalt, men sync til NPMplus fejlede: {e}"
return "gemt lokalt"
async def fetch_user_apps(username: str):
"""Apps brugeren har adgang til, via Authentik-API (service-account-token)."""
headers = {"Authorization": f"Bearer {API_TOKEN}"}
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=headers, timeout=10) as c:
u = await c.get("/api/v3/core/users/", params={"username": username})
results = u.json().get("results", [])
if not results:
return []
pk = results[0]["pk"]
a = await c.get("/api/v3/core/applications/",
params={"for_user": pk, "superuser_full_list": "false", "page_size": 100})
apps = a.json().get("results", [])
out = []
for app_ in apps:
if app_.get("slug") == "weircon-portal":
continue
url = app_.get("launch_url") or app_.get("meta_launch_url")
if not url:
continue # kun apps helt uden launch-URL (authentik-cli, ldap-auth) — vises heller ikke i Authentik
host = url.split("//", 1)[-1].split("/", 1)[0]
out.append({
"slug": app_.get("slug"),
"name": app_.get("name"),
"url": url,
"host": host,
"group": (app_.get("group") or "").strip(),
"desc": (app_.get("meta_description") or "").strip(),
"icon": app_.get("meta_icon") or "",
})
out.sort(key=lambda x: (x["name"] or "").lower())
return out
GROUP_ORDER = {n: i for i, n in enumerate(
["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])}
USERPREFS = DATA / "userprefs.json"
def get_prefs(username):
p = (load_json(USERPREFS, {}) or {}).get(username) or {}
return {"favorites": p.get("favorites") or [], "view": p.get("view") or "grouped"}
def set_prefs(username, **kw):
allp = load_json(USERPREFS, {}) or {}
cur = allp.get(username) or {}
cur.update(kw)
allp[username] = cur
USERPREFS.write_text(json.dumps(allp, ensure_ascii=False, indent=2), "utf-8")
def group_apps(apps):
"""Grupperer apps efter Authentiks group-felt, i en fornuftig rækkefølge."""
groups = {}
for a in apps:
g = a.get("group") or "Øvrige"
groups.setdefault(g, []).append(a)
def gkey(name):
if name == "Øvrige":
return (999, "")
return (GROUP_ORDER.get(name, 50), name.lower())
return [{"name": g, "apps": sorted(groups[g], key=lambda x: (x["name"] or "").lower())}
for g in sorted(groups, key=gkey)]
async def fetch_all_apps_admin():
"""Alle apps (admin-syn) med slug/navn/gruppe/beskrivelse."""
headers = {"Authorization": f"Bearer {API_TOKEN}"}
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=headers, timeout=15) as c:
r = await c.get("/api/v3/core/applications/",
params={"page_size": 100, "superuser_full_list": "true"})
apps = r.json().get("results", [])
out = [{"slug": a["slug"], "name": a.get("name"),
"group": a.get("group") or "", "desc": (a.get("meta_description") or "").strip()}
for a in apps if a.get("slug") != "weircon-portal"]
out.sort(key=lambda x: ((x["group"] or "~~").lower(), (x["name"] or "").lower()))
return out
async def patch_apps(rows):
headers = {"Authorization": f"Bearer {API_TOKEN}"}
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=headers, timeout=20) as c:
async def one(r):
try:
resp = await c.patch(f"/api/v3/core/applications/{r['slug']}/",
json={"meta_description": r["desc"], "group": r["group"]})
return resp.status_code < 300
except Exception:
return False
results = await asyncio.gather(*[one(r) for r in rows])
return sum(1 for ok in results if ok)
def geoip_country(ip):
global _geoip_reader
try:
if _geoip_reader is None:
import maxminddb
_geoip_reader = maxminddb.open_database(GEOIP_DB)
rec = _geoip_reader.get(ip) or {}
c = rec.get("country") or {}
iso = c.get("iso_code") or ""
name = (c.get("names") or {}).get("en") or ""
flag = "".join(chr(0x1F1E6 + ord(ch) - 65) for ch in iso) if len(iso) == 2 and iso.isalpha() else ""
return {"iso": iso, "name": name, "flag": flag}
except Exception:
return {"iso": "", "name": "", "flag": ""}
async def assets_version():
try:
async with httpx.AsyncClient(timeout=6) as c:
r = await c.get(ASSETS_VERSION_URL)
return r.text.strip()[:40]
except Exception:
return None
async def rp_status():
if not RP_API_KEY:
return {"error": "ingen RP_API_KEY konfigureret"}
try:
async with httpx.AsyncClient(timeout=6) as c:
r = await c.get(RP_BASE_URL + "/status", headers={"X-WEIRCON-RANDOM-IP": RP_API_KEY})
d = r.json()
for p in d.get("proxies", []):
p["geo"] = geoip_country(p.get("egress_ip") or "")
rot = d.get("rotation") or {}
if rot.get("next_egress_ip"):
rot["geo"] = geoip_country(rot["next_egress_ip"])
d["inflight_total"] = sum(int(p.get("inflight") or 0) for p in d.get("proxies", []))
return d
except Exception as exc:
return {"error": str(exc)[:200]}
def _ak_headers():
return {"Authorization": f"Bearer {API_TOKEN}"}
async def ak_list_groups():
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=15) as c:
gs = (await c.get("/api/v3/core/groups/", params={"page_size": 100, "ordering": "name"})).json().get("results", [])
return [{"pk": g["pk"], "name": g["name"], "members": len(g.get("users") or [])} for g in gs]
async def ak_list_users():
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=20) as c:
us = (await c.get("/api/v3/core/users/", params={"page_size": 300, "ordering": "username"})).json().get("results", [])
out = []
for u in us:
out.append({"pk": u["pk"], "username": u["username"], "name": u.get("name"),
"email": u.get("email"), "active": u.get("is_active"),
"groups": [str(x) for x in (u.get("groups") or [])],
"group_names": [g.get("name") for g in (u.get("groups_obj") or [])],
"type": u.get("type")})
return out
async def ak_get_user(pk):
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=15) as c:
u = (await c.get(f"/api/v3/core/users/{pk}/")).json()
return {"pk": u["pk"], "username": u["username"], "name": u.get("name"), "email": u.get("email"),
"active": u.get("is_active"), "groups": [str(x) for x in (u.get("groups") or [])]}
async def ak_create_user(username, name, email, password, group_pks):
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=20) as c:
r = await c.post("/api/v3/core/users/", json={
"username": username, "name": name or username, "email": email or "",
"is_active": True, "type": "internal", "path": "users", "groups": group_pks})
if r.status_code >= 300:
return False, r.text[:300]
pk = r.json()["pk"]
if password:
await c.post(f"/api/v3/core/users/{pk}/set_password/", json={"password": password})
return True, pk
async def ak_update_user(pk, group_pks, is_active, password=""):
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=20) as c:
r = await c.patch(f"/api/v3/core/users/{pk}/", json={"groups": group_pks, "is_active": is_active})
if password:
await c.post(f"/api/v3/core/users/{pk}/set_password/", json={"password": password})
return r.status_code < 300
async def ak_create_group(name):
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=15) as c:
r = await c.post("/api/v3/core/groups/", json={"name": name})
return r.status_code < 300
async def ak_group_members(gpk):
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=15) as c:
g = (await c.get(f"/api/v3/core/groups/{gpk}/")).json()
return {"pk": g["pk"], "name": g["name"], "users": [str(x) for x in (g.get("users") or [])]}
async def ak_set_group_members(gpk, user_pks):
desired = set(str(x) for x in user_pks)
async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=25) as c:
g = (await c.get(f"/api/v3/core/groups/{gpk}/")).json()
cur = set(str(x) for x in (g.get("users") or []))
adds = [c.post(f"/api/v3/core/groups/{gpk}/add_user/", json={"pk": int(p)}) for p in desired - cur]
rems = [c.post(f"/api/v3/core/groups/{gpk}/remove_user/", json={"pk": int(p)}) for p in cur - desired]
await asyncio.gather(*adds, *rems)
return True
async def matomo_summary():
if not MATOMO_TOKEN:
return None
params = {
"module": "API", "method": "VisitsSummary.get", "idSite": MATOMO_SITE,
"period": "day", "date": "last7", "format": "JSON", "token_auth": MATOMO_TOKEN,
}
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{MATOMO_BASE}/index.php", params=params)
data = r.json()
days = list(data.values()) if isinstance(data, dict) else []
visits = sum(int(d.get("nb_visits", 0) or 0) for d in days if isinstance(d, dict))
actions = sum(int(d.get("nb_actions", 0) or 0) for d in days if isinstance(d, dict))
return {"visits_7d": visits, "actions_7d": actions}
except Exception:
return None
# ---------------- ruter ----------------
# ---------------- auth ----------------
@app.get("/auth/login")
async def login(request: Request):
return await oauth.authentik.authorize_redirect(request, REDIRECT_URI)
return await oauth.authentik.authorize_redirect(request, config.REDIRECT_URI)
@app.get("/auth/callback")
@@ -352,10 +57,8 @@ async def callback(request: Request):
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 [],
}
"username": info.get("preferred_username"), "email": info.get("email"),
"groups": info.get("groups") or []}
return RedirectResponse("/", status_code=302)
@@ -365,64 +68,61 @@ async def logout(request: Request):
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)
apps, error = [], None
try:
apps = await fetch_user_apps(user["username"])
apps = await ak.fetch_user_apps(user["username"])
except Exception as e:
error = f"Kunne ikke hente apps fra Authentik: {e}"
prefs = get_prefs(user["username"])
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.html", {
"request": request, "user": user, "error": error, "is_admin": is_admin(user),
"groups": group_apps(apps), "apps_sorted": by_name,
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"], "saved": saved,
})
"favorites": list(fav_set), "view": prefs["view"]})
# ---------------- admin ----------------
@app.get("/admin", response_class=HTMLResponse)
async def admin_home(request: Request):
user = current_user(request)
if not is_admin(user):
raise HTTPException(403, "Kun for administratorer")
user = require_admin(request)
return templates.TemplateResponse(request, "admin.html", {
"request": request, "user": user, "modules": ADMIN_MODULES, "is_admin": True,
})
"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 = current_user(request)
if not is_admin(user):
raise HTTPException(403, "Kun for administratorer")
raw = IP_LABELS_LOCAL.read_text("utf-8") if IP_LABELS_LOCAL.exists() else "{}"
data = load_json(IP_LABELS_LOCAL, {})
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,
})
"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):
user = current_user(request)
if not is_admin(user):
raise HTTPException(403, "Kun for administratorer")
require_admin(request)
form = await request.form()
ips = form.getlist("ip")
labels = form.getlist("label")
countries = form.getlist("country")
data = {}
for ip, label, country in zip(ips, labels, countries):
for ip, label, country in zip(form.getlist("ip"), form.getlist("label"), form.getlist("country")):
ip = (ip or "").strip()
if not ip:
continue
@@ -433,182 +133,145 @@ async def admin_iplabels_save(request: Request):
entry["country"] = country.strip().lower()
if entry:
data[ip] = entry
msg = save_ip_labels(data)
return RedirectResponse(f"/admin/ip-labels?saved={msg}", status_code=303)
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 = current_user(request)
if not is_admin(user):
raise HTTPException(403, "Kun for administratorer")
s = load_json(SETTINGS_PATH, {})
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,
})
"saved": saved})
@app.post("/admin/cookies")
async def admin_cookies_save(request: Request, cookie_banner: str = Form(""), cookie_page: str = Form("")):
user = current_user(request)
if not is_admin(user):
raise HTTPException(403, "Kun for administratorer")
s = load_json(SETTINGS_PATH, {})
require_admin(request)
import json
s = store.load_json(config.SETTINGS_PATH, {})
s["cookie_banner"] = cookie_banner
s["cookie_page"] = cookie_page
SETTINGS_PATH.write_text(json.dumps(s, ensure_ascii=False, indent=2), "utf-8")
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):
user = current_user(request)
if not is_admin(user):
raise HTTPException(403, "Kun for administratorer")
version = await assets_version()
rp = await rp_status()
user = require_admin(request)
return templates.TemplateResponse(request, "admin_ops.html", {
"request": request, "user": user, "is_admin": True, "version": version, "rp": rp,
})
"request": request, "user": user, "is_admin": True,
"version": await svc.assets_version(), "rp": await svc.rp_status()})
@app.get("/admin/stats", response_class=HTMLResponse)
async def admin_stats(request: Request):
user = current_user(request)
if not is_admin(user):
raise HTTPException(403, "Kun for administratorer")
stats = await matomo_summary()
user = require_admin(request)
return templates.TemplateResponse(request, "admin_stats.html", {
"request": request, "user": user, "is_admin": True,
"stats": stats, "matomo": MATOMO_BASE,
})
"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 = current_user(request)
if not is_admin(user):
raise HTTPException(403, "Kun for administratorer")
rows = await fetch_all_apps_admin()
user = require_admin(request)
return templates.TemplateResponse(request, "admin_apps.html", {
"request": request, "user": user, "is_admin": True, "rows": rows, "saved": saved,
})
"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):
user = current_user(request)
if not is_admin(user):
raise HTTPException(403, "Kun for administratorer")
require_admin(request)
form = await request.form()
slugs = form.getlist("slug")
descs = form.getlist("desc")
groups = form.getlist("group")
rows = [{"slug": sl, "desc": (d or "").strip(), "group": (g or "").strip()}
for sl, d, g in zip(slugs, descs, groups) if sl]
n = await patch_apps(rows)
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 = current_user(request)
if not is_admin(user):
raise HTTPException(403, "Kun for administratorer")
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,
})
"users": await ak.list_users(), "groups": await ak.list_groups(), "saved": saved})
@app.post("/admin/users")
async def admin_users_create(request: Request):
user = current_user(request)
if not is_admin(user):
raise HTTPException(403)
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"))
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 = current_user(request)
if not is_admin(user):
raise HTTPException(403)
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(),
})
"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 = current_user(request)
if not is_admin(user):
raise HTTPException(403)
require_admin(request)
f = await request.form()
await ak_update_user(pk, f.getlist("groups"), f.get("is_active") == "on", f.get("password") or "")
await ak.update_user(pk, f.getlist("groups"), f.get("is_active") == "on", f.get("password") or "")
cache.clear("users", "apps")
return RedirectResponse("/admin/users?saved=Bruger gemt", status_code=303)
@app.get("/admin/groups", response_class=HTMLResponse)
async def admin_groups(request: Request, saved: str = ""):
user = current_user(request)
if not is_admin(user):
raise HTTPException(403)
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,
})
"groups": await ak.list_groups(), "saved": saved})
@app.post("/admin/groups")
async def admin_groups_create(request: Request):
user = current_user(request)
if not is_admin(user):
raise HTTPException(403)
require_admin(request)
f = await request.form()
name = (f.get("name") or "").strip()
if name:
await ak_create_group(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 = current_user(request)
if not is_admin(user):
raise HTTPException(403)
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(),
})
"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 = current_user(request)
if not is_admin(user):
raise HTTPException(403)
require_admin(request)
f = await request.form()
await ak_set_group_members(gpk, f.getlist("members"))
await ak.set_group_members(gpk, f.getlist("members"))
cache.clear("groups", "users", "apps")
return RedirectResponse("/admin/groups?saved=Medlemmer gemt", status_code=303)
# Offentligt JSON-endpoint så frontend/andre kan læse cookie-teksterne (valgfrit)
# ---------------- api ----------------
@app.get("/api/settings")
async def api_settings():
return JSONResponse(load_json(SETTINGS_PATH, {}))
return JSONResponse(store.load_json(config.SETTINGS_PATH, {}))
@app.post("/api/favorite")
@@ -617,10 +280,10 @@ async def api_favorite(request: 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 = set(get_prefs(user["username"])["favorites"])
favs.add(slug) if body.get("on") else favs.discard(slug)
set_prefs(user["username"], favorites=sorted(favs))
store.set_prefs(user["username"], favorites=sorted(favs))
return {"ok": True}
@@ -631,26 +294,27 @@ async def api_view(request: Request):
raise HTTPException(401)
v = (await request.json()).get("view")
if v in ("grouped", "alpha"):
set_prefs(user["username"], view=v)
store.set_prefs(user["username"], view=v)
return {"ok": True}
@app.get("/api/search-index")
async def search_index(request: Request):
"""Søgeindeks til ⌘K-paletten: brugerens apps + sider."""
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 indstillinger"},
{"type": "page", "title": "IP → label & land", "url": "/admin/ip-labels", "extra": "ip land netværk geo"},
{"type": "page", "title": "Cookie-tekster", "url": "/admin/cookies", "extra": "cookies samtykke"},
{"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo besøg"},
]
{"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": "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"}]
try:
for a in await fetch_user_apps(user["username"]):
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:
@@ -660,4 +324,4 @@ async def search_index(request: Request):
@app.get("/healthz")
async def healthz():
return {"ok": True, "configured": bool(CLIENT_ID and API_TOKEN)}
return {"ok": True, "configured": bool(config.CLIENT_ID and config.API_TOKEN)}