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
+164
View File
@@ -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
+22
View File
@@ -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)
+48
View File
@@ -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."},
]
+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)}
+66
View File
@@ -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
+39
View File
@@ -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
View File
@@ -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 %}
+39
View File
@@ -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 %}