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:
+164
@@ -0,0 +1,164 @@
|
||||
"""Authentik Core-API: apps, brugere, grupper. Læsninger er cachet (cache.py);
|
||||
skrivninger rydder cachen via cache.clear() i kaldsstedet (main.py)."""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
import cache
|
||||
from config import API_TOKEN, AUTHENTIK, GROUP_ORDER
|
||||
|
||||
|
||||
def _h():
|
||||
return {"Authorization": f"Bearer {API_TOKEN}"}
|
||||
|
||||
|
||||
def _client(timeout=15):
|
||||
return httpx.AsyncClient(base_url=AUTHENTIK, headers=_h(), timeout=timeout)
|
||||
|
||||
|
||||
async def fetch_user_apps(username: str):
|
||||
"""Apps brugeren har adgang til. Cachet 45s."""
|
||||
ck = f"apps:{username}"
|
||||
hit = cache.get(ck)
|
||||
if hit is not None:
|
||||
return hit
|
||||
async with _client(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
|
||||
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())
|
||||
cache.put(ck, out, 45)
|
||||
return out
|
||||
|
||||
|
||||
def group_apps(apps):
|
||||
groups = {}
|
||||
for a in apps:
|
||||
groups.setdefault(a.get("group") or "Øvrige", []).append(a)
|
||||
|
||||
def gkey(name):
|
||||
return (999, "") if name == "Øvrige" else (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():
|
||||
hit = cache.get("apps_admin")
|
||||
if hit is not None:
|
||||
return hit
|
||||
async with _client(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()))
|
||||
cache.put("apps_admin", out, 45)
|
||||
return out
|
||||
|
||||
|
||||
async def patch_apps(rows):
|
||||
async with _client(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)
|
||||
|
||||
|
||||
async def list_groups():
|
||||
hit = cache.get("groups")
|
||||
if hit is not None:
|
||||
return hit
|
||||
async with _client(15) as c:
|
||||
gs = (await c.get("/api/v3/core/groups/", params={"page_size": 100, "ordering": "name"})).json().get("results", [])
|
||||
res = [{"pk": g["pk"], "name": g["name"], "members": len(g.get("users") or [])} for g in gs]
|
||||
cache.put("groups", res, 30)
|
||||
return res
|
||||
|
||||
|
||||
async def list_users():
|
||||
hit = cache.get("users")
|
||||
if hit is not None:
|
||||
return hit
|
||||
async with _client(20) as c:
|
||||
us = (await c.get("/api/v3/core/users/", params={"page_size": 300, "ordering": "username"})).json().get("results", [])
|
||||
out = [{"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")}
|
||||
for u in us]
|
||||
cache.put("users", out, 30)
|
||||
return out
|
||||
|
||||
|
||||
async def get_user(pk):
|
||||
async with _client(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 create_user(username, name, email, password, group_pks):
|
||||
async with _client(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 update_user(pk, group_pks, is_active, password=""):
|
||||
async with _client(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 create_group(name):
|
||||
async with _client(15) as c:
|
||||
r = await c.post("/api/v3/core/groups/", json={"name": name})
|
||||
return r.status_code < 300
|
||||
|
||||
|
||||
async def group_members(gpk):
|
||||
async with _client(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 set_group_members(gpk, user_pks):
|
||||
desired = set(str(x) for x in user_pks)
|
||||
async with _client(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 []))
|
||||
ops = [c.post(f"/api/v3/core/groups/{gpk}/add_user/", json={"pk": int(p)}) for p in desired - cur]
|
||||
ops += [c.post(f"/api/v3/core/groups/{gpk}/remove_user/", json={"pk": int(p)}) for p in cur - desired]
|
||||
await asyncio.gather(*ops)
|
||||
return True
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Letvægts in-memory TTL-cache. Authentik-læsninger er I/O-bottleneck (ikke Python),
|
||||
så vi cacher dem kort og rydder ved skrivninger."""
|
||||
import time
|
||||
|
||||
_CACHE = {}
|
||||
|
||||
|
||||
def get(key):
|
||||
ent = _CACHE.get(key)
|
||||
if ent and ent[0] > time.monotonic():
|
||||
return ent[1]
|
||||
return None
|
||||
|
||||
|
||||
def put(key, val, ttl):
|
||||
_CACHE[key] = (time.monotonic() + ttl, val)
|
||||
|
||||
|
||||
def clear(*prefixes):
|
||||
for k in list(_CACHE):
|
||||
if not prefixes or any(k.startswith(p) for p in prefixes):
|
||||
_CACHE.pop(k, None)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Konfiguration (env) + konstanter for portalen."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
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")
|
||||
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"))
|
||||
|
||||
USERPREFS = DATA / "userprefs.json"
|
||||
IP_LABELS_LOCAL = DATA / "ip_labels.json"
|
||||
IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "")
|
||||
SETTINGS_PATH = DATA / "settings.json"
|
||||
|
||||
GROUP_ORDER = {n: i for i, n in enumerate(
|
||||
["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])}
|
||||
|
||||
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."},
|
||||
]
|
||||
@@ -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)}
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
"""Eksterne tjenester: geoip, static-assets-version, random-proxy status, Matomo."""
|
||||
import httpx
|
||||
|
||||
from config import (ASSETS_VERSION_URL, GEOIP_DB, MATOMO_BASE, MATOMO_SITE,
|
||||
MATOMO_TOKEN, RP_API_KEY, RP_BASE_URL)
|
||||
|
||||
_geoip_reader = None
|
||||
|
||||
|
||||
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]}
|
||||
|
||||
|
||||
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 []
|
||||
return {"visits_7d": sum(int(d.get("nb_visits", 0) or 0) for d in days if isinstance(d, dict)),
|
||||
"actions_7d": sum(int(d.get("nb_actions", 0) or 0) for d in days if isinstance(d, dict))}
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Lokal lagring: JSON-filer (bruger-præferencer, cookie-tekster) + IP-label-sync til NPMplus."""
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from config import DATA, IP_LABELS_LOCAL, IP_LABELS_REMOTE, SETTINGS_PATH, USERPREFS # noqa: F401
|
||||
|
||||
|
||||
def load_json(path, default):
|
||||
try:
|
||||
return json.loads(path.read_text("utf-8"))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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")
|
||||
+27
-55
@@ -1,61 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Weircon — forside{% endblock %}
|
||||
|
||||
{% macro card(a) %}
|
||||
<div class="card" data-slug="{{ a.slug }}" data-search="{{ (a.name ~ ' ' ~ a.desc ~ ' ' ~ a.host ~ ' ' ~ a.group)|lower }}">
|
||||
<a class="card-link" href="{{ a.url }}" aria-label="{{ a.name }}"></a>
|
||||
<button type="button" class="fav{% if a.slug in favorites %} on{% endif %}" data-slug="{{ a.slug }}"
|
||||
aria-pressed="{{ 'true' if a.slug in favorites else 'false' }}" title="Markér som favorit" aria-label="Favorit">★</button>
|
||||
<div class="head">
|
||||
<span class="ico">{% if a.icon and a.icon.startswith('http') %}<img src="{{ a.icon }}" alt="">{% else %}{{ (a.name or '?')[0]|upper }}{% endif %}</span>
|
||||
<span class="name">{{ a.name }}</span>
|
||||
</div>
|
||||
<div class="sub" title="{{ a.desc if a.desc else a.host }}">{{ a.desc if a.desc else a.host }}</div>
|
||||
<div class="go">Åbn →</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block body %}
|
||||
<h1>Hej, {{ user.name }}</h1>
|
||||
<p class="lead">Tjenester du har adgang til.</p>
|
||||
{% if error %}<div class="err">{{ error }}</div>{% endif %}
|
||||
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
|
||||
{% if groups %}
|
||||
<div class="toolbar">
|
||||
<div class="filter">
|
||||
<span class="mag">⌕</span>
|
||||
<input id="appfilter" type="search" placeholder="Filtrér…" autocomplete="off" aria-label="Filtrér tjenester" />
|
||||
</div>
|
||||
<div class="viewtoggle" role="group" aria-label="Visning">
|
||||
<button type="button" data-view="grouped" class="{{ 'on' if view != 'alpha' }}">Grupperet</button>
|
||||
<button type="button" data-view="alpha" class="{{ 'on' if view == 'alpha' }}">Alfabetisk</button>
|
||||
|
||||
<div id="dash">
|
||||
<div class="grid" style="margin-top:1.6rem" aria-hidden="true">
|
||||
{% for i in range(8) %}
|
||||
<div class="card skel"><div class="head"><span class="ico"></span><span class="sk" style="width:55%;height:1em"></span></div><span class="sk" style="width:80%;height:.8em;margin-top:.8rem"></span></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="favsec" class="appgroup"{% if not fav_apps %} style="display:none"{% endif %}>
|
||||
<h2>★ Favoritter</h2>
|
||||
<div class="grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
|
||||
</section>
|
||||
|
||||
<div id="view-grouped"{% if view == 'alpha' %} hidden{% endif %}>
|
||||
{% for g in groups %}
|
||||
<section class="appgroup"><h2>{{ g.name }}</h2><div class="grid">{% for a in g.apps %}{{ card(a) }}{% endfor %}</div></section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="view-alpha"{% if view != 'alpha' %} hidden{% endif %}>
|
||||
<section class="appgroup"><h2>Alle tjenester</h2><div class="grid">{% for a in apps_sorted %}{{ card(a) }}{% endfor %}</div></section>
|
||||
</div>
|
||||
<p id="nomatch" class="muted" style="display:none;margin-top:1rem">Ingen tjenester matcher.</p>
|
||||
{% elif not error %}
|
||||
<p class="muted" style="margin-top:1.6rem">Ingen apps fundet for din konto endnu.</p>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
<section class="appgroup">
|
||||
<h2>Administration</h2>
|
||||
<div class="grid">
|
||||
<a class="card" href="/admin" style="position:relative">
|
||||
<div class="head"><span class="ico">⚙️</span><span class="name">Admin-panel</span></div>
|
||||
<div class="sub">IP-labels, cookies, statistik, app-beskrivelser m.m.</div>
|
||||
<div class="sub">Brugere, grupper, drift, ikoner m.m.</div>
|
||||
<div class="go">Åbn →</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -75,6 +39,9 @@
|
||||
border:0; background:transparent; color:var(--border); cursor:pointer; font-size:1.05rem; line-height:1; padding:0; transition:color .15s, transform .15s; }
|
||||
.fav:hover { color:var(--muted); transform:scale(1.15); }
|
||||
.fav.on { color:#f5c518; }
|
||||
.card.skel { opacity:.5; pointer-events:none; }
|
||||
.card.skel .ico { background:var(--surface2); }
|
||||
.sk { display:inline-block; background:var(--surface2); border-radius:5px; }
|
||||
[hidden] { display:none !important; }
|
||||
</style>
|
||||
<script>
|
||||
@@ -90,29 +57,33 @@
|
||||
b.classList.toggle('on',on); b.setAttribute('aria-pressed', on?'true':'false');
|
||||
});
|
||||
var favgrid=document.getElementById('favgrid'), favsec=document.getElementById('favsec');
|
||||
if(!favgrid) return;
|
||||
var has=favgrid.querySelector('.card[data-slug="'+CSS.escape(slug)+'"]');
|
||||
if(on && !has){
|
||||
var src=document.querySelector('#view-grouped .card[data-slug="'+CSS.escape(slug)+'"]') || document.querySelector('.card[data-slug="'+CSS.escape(slug)+'"]');
|
||||
if(src){ var c=src.cloneNode(true); favgrid.appendChild(c); }
|
||||
if(src){ favgrid.appendChild(src.cloneNode(true)); }
|
||||
} else if(!on && has){ has.remove(); }
|
||||
favsec.style.display = favgrid.children.length ? '' : 'none';
|
||||
if(favsec) favsec.style.display = favgrid.children.length ? '' : 'none';
|
||||
}
|
||||
// delegation: virker også for async-indlæst indhold
|
||||
document.addEventListener('click',function(e){
|
||||
var fav=e.target.closest && e.target.closest('.fav');
|
||||
if(fav){ e.preventDefault(); e.stopPropagation();
|
||||
var slug=fav.getAttribute('data-slug'), on=!fav.classList.contains('on');
|
||||
setFav(slug,on);
|
||||
fetch('/api/favorite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug:slug,on:on})});
|
||||
return;
|
||||
}
|
||||
return; }
|
||||
var vb=e.target.closest && e.target.closest('.viewtoggle button');
|
||||
if(vb){ var v=vb.getAttribute('data-view'); setView(v);
|
||||
fetch('/api/view',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({view:v})});
|
||||
}
|
||||
fetch('/api/view',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({view:v})}); }
|
||||
});
|
||||
// filter på tværs af synlige kort
|
||||
var inp=document.getElementById('appfilter');
|
||||
if(inp){
|
||||
document.addEventListener('keydown',function(e){
|
||||
var inp=document.getElementById('appfilter');
|
||||
if(inp && e.key==='/' && document.activeElement!==inp && !/input|textarea/i.test(document.activeElement.tagName)){ e.preventDefault(); inp.focus(); }
|
||||
});
|
||||
function initDash(){
|
||||
var inp=document.getElementById('appfilter');
|
||||
if(!inp || inp.__wired) return; inp.__wired=true;
|
||||
inp.addEventListener('input',function(){
|
||||
var q=inp.value.trim().toLowerCase(), shown=0;
|
||||
document.querySelectorAll('.card[data-search]').forEach(function(c){
|
||||
@@ -125,10 +96,11 @@
|
||||
});
|
||||
var nm=document.getElementById('nomatch'); if(nm) nm.style.display=shown?'none':'block';
|
||||
});
|
||||
document.addEventListener('keydown',function(e){
|
||||
if(e.key==='/' && document.activeElement!==inp && !/input|textarea/i.test(document.activeElement.tagName)){ e.preventDefault(); inp.focus(); }
|
||||
});
|
||||
}
|
||||
fetch('/dashboard-content',{headers:{'X-Requested-With':'fetch'}})
|
||||
.then(function(r){ if(!r.ok) throw 0; return r.text(); })
|
||||
.then(function(html){ document.getElementById('dash').innerHTML=html; initDash(); })
|
||||
.catch(function(){ document.getElementById('dash').innerHTML='<p class="muted" style="margin-top:1.6rem">Kunne ikke hente tjenester — prøv at genindlæse siden.</p>'; });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{% macro card(a) %}
|
||||
<div class="card" data-slug="{{ a.slug }}" data-search="{{ (a.name ~ ' ' ~ a.desc ~ ' ' ~ a.host ~ ' ' ~ a.group)|lower }}">
|
||||
<a class="card-link" href="{{ a.url }}" aria-label="{{ a.name }}"></a>
|
||||
<button type="button" class="fav{% if a.slug in favorites %} on{% endif %}" data-slug="{{ a.slug }}"
|
||||
aria-pressed="{{ 'true' if a.slug in favorites else 'false' }}" title="Markér som favorit" aria-label="Favorit">★</button>
|
||||
<div class="head">
|
||||
<span class="ico">{% if a.icon and a.icon.startswith('http') %}<img src="{{ a.icon }}" alt="">{% else %}{{ (a.name or '?')[0]|upper }}{% endif %}</span>
|
||||
<span class="name">{{ a.name }}</span>
|
||||
</div>
|
||||
<div class="sub" title="{{ a.desc if a.desc else a.host }}">{{ a.desc if a.desc else a.host }}</div>
|
||||
<div class="go">Åbn →</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
{% if error %}<div class="err">{{ error }}</div>{% endif %}
|
||||
{% if groups %}
|
||||
<div class="toolbar">
|
||||
<div class="filter"><span class="mag">⌕</span>
|
||||
<input id="appfilter" type="search" placeholder="Filtrér…" autocomplete="off" aria-label="Filtrér tjenester"></div>
|
||||
<div class="viewtoggle" role="group" aria-label="Visning">
|
||||
<button type="button" data-view="grouped" class="{{ 'on' if view != 'alpha' }}">Grupperet</button>
|
||||
<button type="button" data-view="alpha" class="{{ 'on' if view == 'alpha' }}">Alfabetisk</button>
|
||||
</div>
|
||||
</div>
|
||||
<section id="favsec" class="appgroup"{% if not fav_apps %} style="display:none"{% endif %}>
|
||||
<h2>★ Favoritter</h2>
|
||||
<div class="grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
|
||||
</section>
|
||||
<div id="view-grouped"{% if view == 'alpha' %} hidden{% endif %}>
|
||||
{% for g in groups %}
|
||||
<section class="appgroup"><h2>{{ g.name }}</h2><div class="grid">{% for a in g.apps %}{{ card(a) }}{% endfor %}</div></section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="view-alpha"{% if view != 'alpha' %} hidden{% endif %}>
|
||||
<section class="appgroup"><h2>Alle tjenester</h2><div class="grid">{% for a in apps_sorted %}{{ card(a) }}{% endfor %}</div></section>
|
||||
</div>
|
||||
<p id="nomatch" class="muted" style="display:none;margin-top:1rem">Ingen tjenester matcher.</p>
|
||||
{% elif not error %}
|
||||
<p class="muted" style="margin-top:1.6rem">Ingen apps fundet for din konto endnu.</p>
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user