Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
328 lines
13 KiB
Python
328 lines
13 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 authentik as ak
|
|
import cache
|
|
import config
|
|
import services as svc
|
|
import store
|
|
|
|
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)
|
|
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}"
|
|
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"]})
|
|
|
|
|
|
# ---------------- 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):
|
|
user = require_admin(request)
|
|
return templates.TemplateResponse(request, "admin_ops.html", {
|
|
"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 = 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):
|
|
require_admin(request)
|
|
f = await request.form()
|
|
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 = 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):
|
|
require_admin(request)
|
|
f = await request.form()
|
|
await ak.set_group_members(gpk, f.getlist("members"))
|
|
cache.clear("groups", "users", "apps")
|
|
return RedirectResponse("/admin/groups?saved=Medlemmer 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": "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 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)}
|