Files
axion1337.chat-gitops/apps/production/wikijs-config.py
T
Thore CimbalandClaude Opus 4.8 2250969e4a feat(wikijs): German default locale, migrate content de, Berlin timezone
The content is German but was bound to the 'en' locale, the site default
language was English, and system accounts defaulted to America/New_York.

- ensure_locale: set default locale to 'de' (live via updateLocale) and
  migrate all pages en->de via pages.migrateToLocale, then rebuild page tree
  and search index only when something was migrated.
- Navigation tree now stored under locale 'de' so getTree finds it for the
  now-German pages.
- ensure_timezones: set guest + admin system accounts to Europe/Berlin
  (new auto-provisioned users still inherit the core-hardcoded NY default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:00:00 +00:00

466 lines
24 KiB
Python

#!/usr/bin/env python3
"""wikijs-config.py — idempotenter Bootstrap + Konfiguration von Wiki.js.
Deploybares Äquivalent des manuellen Setup-Assistenten (ADR-0014, #0048/#0049).
Läuft als k8s-Job, nachdem Wiki.js oben ist; treibt die Admin-GraphQL-API. Nur
stdlib. Alle Verträge wurden am 2026-08-12 live gegen die laufende Instanz geprüft.
Ablauf:
warten -> (falls Setup) /finalize mit SOPS-Admin -> warten auf Normalmodus
-> login -> Gruppen + Seitenregeln (Abschottung) -> OIDC-Strategy -> hideLocal
-> Theming/Branding -> Navigation (Custom-Sidebar) -> HTML-Rendering (allowHTML)
-> Git-Storage.
Idempotent: jeder Schritt prüft erst den Ist-Zustand. Seiten-Inhalt (Startseite,
Anwender-/Betriebsseiten) liegt in git-storage, nicht in diesem Job.
Variante B ("kein manuelles Login"): local wird NICHT deaktiviert, sondern nur per
Wiki.js-eigenem hideLocal aus der Login-Seite ausgeblendet. Grund: (a) local ist
nicht löschbar und Wiki.js braucht eine Formular-Strategie, sonst rendert die
Login-Seite leer; (b) der Job selbst loggt sich über local ein; (c) Break-Glass
bleibt über /login?all erreichbar.
"""
from __future__ import annotations
import json
import os
import sys
import time
import urllib.request
import urllib.error
WIKI = os.environ.get("WIKI_URL", "http://wikijs:3000")
ADMIN_EMAIL = os.environ["WIKI_ADMIN_EMAIL"]
ADMIN_PW = os.environ["WIKI_ADMIN_PASSWORD"]
SITE_URL = os.environ.get("WIKI_SITE_URL", "https://wiki.axion1337.chat")
CLIENT_ID = os.environ["OIDC_CLIENT_ID"]
CLIENT_SECRET = os.environ["OIDC_CLIENT_SECRET"]
AUTH = os.environ.get("AUTHENTIK_URL", "https://auth.axion1337.chat")
APP_SLUG = os.environ.get("OIDC_APP_SLUG", "wiki-js")
# Der Strategy-Key bestimmt die Callback-URL (/login/<key>/callback) und MUSS mit
# der redirect_uri im Authentik-Provider übereinstimmen.
STRATEGY_KEY = os.environ.get("OIDC_STRATEGY_KEY", "d3e7d0e4-adff-4421-b016-7758c44fd697")
ADMIN_PERMS = [
"manage:system", "manage:users", "manage:groups", "manage:navigation",
"manage:pages", "write:pages", "read:pages", "manage:assets", "write:assets",
"read:assets", "manage:comments", "write:comments", "read:comments",
"read:history", "read:source", "write:styles", "write:scripts",
"manage:theme", "manage:api",
]
READER_PERMS = ["read:pages", "read:assets", "read:comments"]
# App-/Inhalts-UI-CSS (#0050 + UX-Fixes). Gilt NICHT für die Login-Seite (die rendert
# Wiki.js ohne Custom-CSS).
# - Content-Links: Akzent + Unterstreichung, damit klar als Link erkennbar. Die frühere
# breite .primary--text-Einfärbung ließ Nicht-Links wie Links aussehen -> raus.
# - Startseiten-Kacheln + Schnellzugriff-Buttons (die Startseite nutzt HTML, s. allowHTML).
ACCENT_CSS = (
".v-application .contents a{color:#2b6cb0;text-decoration:underline}"
".theme--dark.v-application .contents a{color:#63b3ed}"
".v-application .contents a.toc-anchor{color:inherit!important;text-decoration:none!important}"
".wiki-hero{background:rgba(255,255,255,.05);border-radius:10px;padding:26px 24px;margin:8px 0 20px}"
".wiki-hero h1{border:none!important;margin:0 0 6px}.wiki-hero p{margin:0;color:#9ca3af}"
".wiki-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin:10px 0 26px}"
".wiki-card{display:flex!important;flex-direction:column;gap:6px;padding:20px;border-radius:10px;"
"background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.10);text-decoration:none!important}"
".wiki-card:hover{background:rgba(99,179,237,.12);border-color:#63b3ed}"
".wiki-card .wc-t{font-size:1.15rem;font-weight:600;color:#e5e7eb}"
".wiki-card .wc-d{color:#9ca3af;font-size:.9rem}"
".wiki-btns{display:flex;flex-wrap:wrap;gap:10px;margin:6px 0 22px}"
".wiki-btn{display:inline-block;padding:9px 16px;border-radius:6px;background:#2b6cb0;color:#fff!important;"
"text-decoration:none!important;font-size:.92rem;font-weight:500}"
".wiki-btn:hover{background:#63b3ed}"
)
def log(msg: str) -> None:
print(f"[wikijs-config] {msg}", flush=True)
def req(path: str, data=None, headers=None, method=None):
body = json.dumps(data).encode() if data is not None else None
r = urllib.request.Request(WIKI + path, data=body, method=method or ("POST" if body else "GET"))
r.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
r.add_header(k, v)
with urllib.request.urlopen(r, timeout=30) as resp:
raw = resp.read().decode()
return resp.status, (json.loads(raw) if raw else {})
def gql(query: str, jwt: str | None = None, variables: dict | None = None):
h = {"Authorization": f"Bearer {jwt}"} if jwt else {}
_, d = req("/graphql", {"query": query, "variables": variables or {}}, h)
if d.get("errors"):
raise RuntimeError(json.dumps(d["errors"]))
return d["data"]
def wait_reachable():
# Startseite ist HTML, nicht JSON -> nicht über req()/json.loads prüfen.
for _ in range(60):
try:
with urllib.request.urlopen(WIKI + "/", timeout=10) as r:
if r.status < 500:
return
except urllib.error.HTTPError as e:
if e.code < 500:
return
except Exception:
pass
time.sleep(3)
sys.exit("Wiki.js nicht erreichbar")
def in_setup_mode() -> bool:
# Normalmodus: die (öffentliche) login-Mutation ist beantwortbar. Setup-Modus:
# das Haupt-Schema ist nicht geladen -> die Mutation wirft.
try:
gql('mutation{authentication{login(username:"probe@invalid",password:"x",'
'strategy:"local"){responseResult{succeeded}}}}')
return False
except Exception:
return True
def finalize():
log("Setup-Modus -> finalize mit SOPS-Admin")
_, d = req("/finalize", {
"adminEmail": ADMIN_EMAIL,
"adminPassword": ADMIN_PW,
"adminPasswordConfirm": ADMIN_PW,
"siteUrl": SITE_URL,
"telemetry": False,
})
if not d.get("ok"):
sys.exit(f"finalize fehlgeschlagen: {d}")
log("finalize ok, warte auf Normalmodus (Neustart)")
time.sleep(10)
for _ in range(40):
if not in_setup_mode():
return
time.sleep(3)
sys.exit("Normalmodus nicht erreicht")
def login():
# Gibt JWT zurück. local bleibt bewusst aktiviert (Break-Glass + der Job loggt sich
# damit ein) — None nur, falls local doch mal deaktiviert wurde, dann kann der Job
# nichts tun.
d = gql(
'mutation($u:String!,$p:String!){authentication{login(username:$u,password:$p,'
'strategy:"local"){responseResult{succeeded message} jwt}}}',
variables={"u": ADMIN_EMAIL, "p": ADMIN_PW},
)["authentication"]["login"]
return d["jwt"] if d["responseResult"]["succeeded"] else None
def _set_strategies(jwt: str, oidc_config: list):
strategies = [
{ # local bleibt aktiviert: der Konfig-Job loggt sich damit ein und es ist der
# Break-Glass-Zugang (/login?all). Auf der Login-Seite wird es per hideLocal
# ausgeblendet (siehe set_hide_local) — kein manuelles Login im Alltag.
"key": "local", "strategyKey": "local", "displayName": "Local",
"order": 0, "isEnabled": True, "selfRegistration": False,
"domainWhitelist": [], "autoEnrollGroups": [], "config": [],
},
{
"key": STRATEGY_KEY, "strategyKey": "oidc", "displayName": "Authentik",
"order": 1, "isEnabled": True, "selfRegistration": True,
"domainWhitelist": [], "autoEnrollGroups": [], "config": oidc_config,
},
]
r = gql(
'mutation($s:[AuthenticationStrategyInput]!){authentication{updateStrategies(strategies:$s)'
'{responseResult{succeeded message}}}}',
jwt, {"s": strategies},
)["authentication"]["updateStrategies"]["responseResult"]
if not r["succeeded"]:
sys.exit(f"updateStrategies fehlgeschlagen: {r['message']}")
def ensure_oidc(jwt: str):
# Quellcode-verifiziert: Wiki.js liest jeden Config-Wert via
# _.get(JSON.parse(value), 'v', null) -> der Wert MUSS als {"v": …} kodiert sein.
cfg = {
"clientId": CLIENT_ID, "clientSecret": CLIENT_SECRET,
"authorizationURL": f"{AUTH}/application/o/authorize/",
"tokenURL": f"{AUTH}/application/o/token/",
"userInfoURL": f"{AUTH}/application/o/userinfo/",
"issuer": f"{AUTH}/application/o/{APP_SLUG}/",
"logoutURL": f"{AUTH}/application/o/{APP_SLUG}/end-session/",
"emailClaim": "email", "displayNameClaim": "name",
"groupsClaim": "groups", "mapGroups": True,
}
oidc_config = [{"key": k, "value": json.dumps({"v": v})} for k, v in cfg.items()]
_set_strategies(jwt, oidc_config)
log("OIDC-Strategy gesetzt (local bleibt aktiv als Break-Glass)")
def set_hide_local(jwt: str):
# Variante B ohne local zu deaktivieren: Wiki.js' eingebautes hideLocal entfernt den
# local-Provider aus der Login-Seite (nur noch "Login with Authentik" sichtbar).
# Break-Glass bleibt über /login?all. authHideLocal ist ein Site-Config-Feld.
r = gql('mutation{site{updateConfig(authHideLocal:true){responseResult{succeeded message}}}}',
jwt)["site"]["updateConfig"]["responseResult"]
if not r["succeeded"]:
sys.exit(f"authHideLocal setzen fehlgeschlagen: {r['message']}")
log("Login-Seite: local ausgeblendet (hideLocal=true; Break-Glass via /login?all)")
def ensure_theming(jwt: str):
# Branding kommt aus gemounteten statischen Dateien (/_assets/img/branding/, siehe
# wikijs.yaml + platform-branding ConfigMap): öffentlich ausgeliefert, kein read:assets
# für Guests, keine externe URL. Dark als Default (#0050).
logo = "/_assets/img/branding/logo.png"
bg = "/_assets/img/branding/alpenglow.jpg"
tc = gql('{theming{config{theme iconset darkMode tocPosition injectCSS injectHead injectBody}}}',
jwt)["theming"]["config"]
gql('mutation($t:String!,$i:String!,$d:Boolean!,$tp:String,$c:String,$h:String,$b:String){'
'theming{setConfig(theme:$t,iconset:$i,darkMode:$d,tocPosition:$tp,injectCSS:$c,injectHead:$h,'
'injectBody:$b){responseResult{succeeded message}}}}',
jwt, {"t": tc["theme"], "i": tc["iconset"], "d": True, "tp": "right",
"c": ACCENT_CSS, "h": tc["injectHead"], "b": tc["injectBody"]})
# Site-Titel (oben links / Browser-Tab) aus der Deployment-Variable — nicht mehr
# das hartkodierte "Wiki.js". Reproduzierbar über WIKI_SITE_TITLE.
title = os.environ.get("WIKI_SITE_TITLE", "Wiki").strip() or "Wiki"
gql('mutation($bg:String!,$logo:String!,$ttl:String!){site{updateConfig('
'authLoginBgUrl:$bg,logoUrl:$logo,title:$ttl){responseResult{succeeded message}}}}',
jwt, {"bg": bg, "logo": logo, "ttl": title})
log(f"Theming gesetzt (Titel '{title}', Dark-Default, Logo + Hintergrund aus Mounts)")
def ensure_git_storage(jwt: str):
# Git-Storage: Wiki.js-Inhalt nach Gitea syncen (ADR-0015). Der Cluster erreicht
# git.lab nicht -> Gitea ist das Ziel, ein CI-Job kanonisiert Gitea->git.lab.
# Nur der PAT (GIT_STORAGE_TOKEN) ist ein Secret; Repo/User/Branch sind Klartext.
# Fehlt der Token, wird der Schritt übersprungen -> der Job bleibt lauffähig, bevor
# der Storage eingerichtet ist. Config-Werte brauchen die {"v":…}-Kodierung.
token = os.environ.get("GIT_STORAGE_TOKEN", "").strip()
repo = os.environ.get("GIT_STORAGE_REPO", "").strip()
user = os.environ.get("GIT_STORAGE_USER", "").strip()
if not (token and repo and user):
log("Git-Storage übersprungen (GIT_STORAGE_TOKEN/REPO/USER nicht gesetzt)")
return
cfg = {
"authType": "basic", "repoUrl": repo,
"branch": os.environ.get("GIT_STORAGE_BRANCH", "main"),
"basicUsername": user, "basicPassword": token, "verifySSL": True,
"defaultEmail": os.environ.get("GIT_STORAGE_EMAIL", "wiki@localhost"),
"defaultName": os.environ.get("GIT_STORAGE_NAME", "Wiki"),
"sshPrivateKeyMode": "path", "localRepoPath": "./data/repo",
"alwaysNamespace": False, "gitBinaryPath": "",
}
config = [{"key": k, "value": json.dumps({"v": v})} for k, v in cfg.items()]
target = {"isEnabled": True, "key": "git", "mode": "sync",
"syncInterval": "PT5M", "config": config}
r = gql('mutation($t:[StorageTargetInput]!){storage{updateTargets(targets:$t)'
'{responseResult{succeeded message}}}}',
jwt, {"t": [target]})["storage"]["updateTargets"]["responseResult"]
if not r["succeeded"]:
sys.exit(f"Git-Storage fehlgeschlagen: {r['message']}")
log(f"Git-Storage aktiviert (Gitea, sync): {repo}")
def set_navigation(jwt: str):
# Eigenes Sidebar-Menü (STATIC) statt des Browse-Baums: Home + Anwender + Betrieb.
# Betrieb-Einträge nur für Admin-Gruppen sichtbar (1 Administrators, 3 authentik
# Admins); die Seiten sind ohnehin per checkAccess für Anwender gesperrt.
admin = [1, 3]
def lnk(i, label, icon, path, g=None):
# Das Default-Theme rendert den Sidebar-Link wortwörtlich als href=target (ohne
# targetType-/Slash-Behandlung). Page-Targets MUESSEN daher absolut sein, sonst
# loesen sie relativ auf (von /betrieb/x aus -> /betrieb/<target> -> 404). Wiki.js'
# eigener Editor speichert /<locale>/<path>; hier /<path> (namespacing=false), passend
# zu allen Inhalts-Links.
return {"id": i, "kind": "link", "label": label, "icon": icon,
"targetType": "page", "target": "/" + path,
"visibilityMode": "restricted" if g else "all", "visibilityGroups": g or []}
def hdr(i, label, g=None):
return {"id": i, "kind": "header", "label": label, "icon": "",
"targetType": "external", "target": "",
"visibilityMode": "restricted" if g else "all", "visibilityGroups": g or []}
items = [
{"id": "home", "kind": "link", "label": "Startseite", "icon": "mdi-home",
"targetType": "home", "target": "/", "visibilityMode": "all", "visibilityGroups": []},
hdr("hanw", "Anwenderdokumentation"),
lnk("a1", "Registrierung", "mdi-account-plus", "anwender/registrierung"),
lnk("a2", "Anmelden & Konto", "mdi-login", "anwender/anmelden-und-konto"),
lnk("a3", "Erste Schritte", "mdi-rocket-launch-outline", "anwender/erste-schritte"),
lnk("a4", "Nachrichten", "mdi-message-text", "anwender/nachrichten"),
lnk("a5", "Anrufe & Screensharing", "mdi-phone", "anwender/anrufe"),
lnk("a6", "Verschlüsselung", "mdi-lock", "anwender/verschluesselung"),
lnk("a7", "Benachrichtigungen", "mdi-bell", "anwender/benachrichtigungen"),
lnk("a8", "ThreadNet Desktop", "mdi-desktop-classic", "anwender/threadnet-desktop-setup"),
hdr("hbet", "Betriebsdokumentation", admin),
lnk("b0", "Einführung & Architektur", "mdi-sitemap", "betrieb/einfuehrung", admin),
lnk("b1", "Installation", "mdi-download", "betrieb/installation", admin),
lnk("b2", "ConfigMap-Sync", "mdi-sync", "betrieb/configmap-sync", admin),
lnk("b3", "Authentik OIDC", "mdi-shield-account", "betrieb/authentik-oidc", admin),
lnk("b4", "Element / ThreadNet", "mdi-palette", "betrieb/element-customization", admin),
lnk("b5", "Element Call & MatrixRTC", "mdi-video", "betrieb/element-call", admin),
lnk("b6", "TURN Server", "mdi-server-network", "betrieb/turn-server", admin),
lnk("b7", "Moderation & Scanning", "mdi-shield-check", "betrieb/moderation-content-scanning", admin),
lnk("b8", "Monitoring", "mdi-chart-line", "betrieb/monitoring", admin),
lnk("b9", "Backups", "mdi-backup-restore", "betrieb/backups", admin),
lnk("b10", "Host-Wartung", "mdi-wrench", "betrieb/host-maintenance-notifications", admin),
lnk("b11", "Room Policies", "mdi-gavel", "betrieb/room-policies", admin),
lnk("b12", "Upgrades", "mdi-update", "betrieb/upgrades", admin),
lnk("b13", "Sicherheit", "mdi-shield-lock", "betrieb/sicherheit", admin),
]
gql('mutation($m:NavigationMode!){navigation{updateConfig(mode:$m){responseResult{succeeded}}}}',
jwt, {"m": "STATIC"})
# Nav-Baum unter der Standard-Locale 'de' (die Seiten liegen nach der Migration unter
# de; getTree wird mit der Seiten-Locale abgefragt — ein en-Baum bliebe unsichtbar).
gql('mutation($t:[NavigationTreeInput]!){navigation{updateTree(tree:$t)'
'{responseResult{succeeded message}}}}', jwt, {"t": [{"locale": "de", "items": items}]})
log("Navigation gesetzt (Custom-Sidebar; Betrieb nur für Admins)")
def ensure_html_rendering(jwt: str):
# Die Startseite nutzt HTML-Kacheln -> markdownCore.allowHTML muss an sein. Wiki.js'
# updateRenderers-Resolver liest jeden Wert via _.get(JSON.parse(value),'v',null),
# also {"v":…}-kodiert; die übrigen Renderer-Werte müssen mitgesendet werden, sonst
# werden sie genullt.
# ⚠️ Wiki.js lädt die Renderer-Config beim START. Auf einer FRISCHEN Instanz greift
# allowHTML erst nach einem Wiki.js-Neustart; die Startseite rendert dann beim
# nächsten git-Sync korrekt (sonst erscheint sie kurz als HTML-Text). Idempotent.
rs = gql('{rendering{renderers{isEnabled key config{key value}}}}', jwt)["rendering"]["renderers"]
def actual(v):
try:
p = json.loads(v)
return p["value"] if isinstance(p, dict) and "value" in p else p
except Exception:
return v
out = []
for r in rs:
cfg = []
for c in (r["config"] or []):
a = actual(c["value"])
if r["key"] == "markdownCore" and c["key"] == "allowHTML":
a = True
cfg.append({"key": c["key"], "value": json.dumps({"v": a})})
out.append({"isEnabled": r["isEnabled"], "key": r["key"], "config": cfg})
gql('mutation($r:[RendererInput]){rendering{updateRenderers(renderers:$r)'
'{responseResult{succeeded message}}}}', jwt, {"r": out})
log("HTML-Rendering aktiviert (markdownCore.allowHTML) — Neustart nötig auf frischer Instanz")
def ensure_locale(jwt: str):
# Deutsch als Standardsprache: die Inhalte sind deutsch, hingen aber an 'en'.
# updateLocale lädt live (setCurrentLocale + refreshNamespaces, KEIN Neustart);
# namespacing bleibt aus (eine Sprache, saubere /<pfad>-URLs), 'en' bleibt als
# Namespace registriert.
loc = gql('{localization{locales{code isInstalled} config{locale namespaces}}}',
jwt)["localization"]
installed = {l["code"] for l in loc["locales"] if l["isInstalled"]}
if "de" not in installed:
# de muss in der locales-Tabelle liegen (updateLocale liest dort isRTL). Auf
# frischen Instanzen ggf. herunterladen (braucht Egress) — weich, kein Hard-Fail.
try:
gql('mutation{localization{downloadLocale(locale:"de")'
'{responseResult{succeeded message}}}}', jwt)
log("Locale 'de' heruntergeladen")
except Exception as e: # noqa: BLE001
log(f"WARN: downloadLocale(de) fehlgeschlagen ({e}) — Locale-Umstellung übersprungen")
return
if loc["config"]["locale"] != "de":
ns = sorted(set((loc["config"].get("namespaces") or []) + ["de", "en"]))
gql('mutation($l:String!,$a:Boolean!,$n:Boolean!,$ns:[String]!){localization{'
'updateLocale(locale:$l,autoUpdate:$a,namespacing:$n,namespaces:$ns)'
'{responseResult{succeeded message}}}}',
jwt, {"l": "de", "a": True, "n": False, "ns": ns})
log("Standardsprache auf Deutsch gesetzt (de)")
# Inhalte en->de migrieren (patcht nur die pages-Tabelle; das ist auch Wiki.js'
# eigener Locale-Migrationsweg, kollisions-sicher via whereNotExists). Danach Tree
# + Suchindex neu bauen, aber NUR wenn wirklich migriert wurde — sonst teurer No-op
# bei jedem Job-Lauf. (git-storage-Frontmatter der Altdateien bleibt 'en' und heilt
# beim nächsten Bearbeiten; dormante Dateien werden nicht re-importiert.)
m = gql('mutation{pages{migrateToLocale(sourceLocale:"en",targetLocale:"de")'
'{responseResult{succeeded message} count}}}', jwt)["pages"]["migrateToLocale"]
if (m.get("count") or 0) > 0:
gql('mutation{pages{rebuildTree{responseResult{succeeded}}}}', jwt)
gql('mutation{search{rebuildIndex{responseResult{succeeded}}}}', jwt)
log(f"Inhalte nach de migriert ({m['count']} Seiten) + Tree/Suchindex neu gebaut")
else:
log("Locale-Migration: keine en-Seiten offen (bereits de)")
def ensure_timezones(jwt: str):
# Systemkonten auf Europe/Berlin statt Wiki.js-Default America/New_York: guest
# (id 2, treibt die anonyme/Standard-Anzeige) + der Admin. Menschliche OIDC-Nutzer
# setzen ihre TZ im Profil; NEUE Nutzer erben aber weiter den im Core hardcodierten
# New_York-Default (server/core/auth.js) — nur per Fork-Patch änderbar, nicht per
# Config. update patcht nur das timezone-Feld (kein Nulling, groups nur bei Array).
ids = {2} # guest
for u in gql("{users{list{id email}}}", jwt)["users"]["list"]:
if u["email"] == ADMIN_EMAIL:
ids.add(u["id"])
for uid in sorted(ids):
gql('mutation($id:Int!){users{update(id:$id,timezone:"Europe/Berlin")'
'{responseResult{succeeded message}}}}', jwt, {"id": uid})
log(f"Zeitzone Europe/Berlin für Systemkonten gesetzt (ids {sorted(ids)})")
def group_id(jwt: str, name: str):
for g in gql("{groups{list{id name}}}", jwt)["groups"]["list"]:
if g["name"] == name:
return g["id"]
return None
def ensure_group(jwt: str, name: str, perms: list, rules: list):
gid = group_id(jwt, name)
if gid is None:
gql('mutation($n:String!){groups{create(name:$n){responseResult{succeeded message}}}}',
jwt, {"n": name})
gid = group_id(jwt, name)
log(f"Gruppe '{name}' angelegt (id {gid})")
gql(
'mutation($id:Int!,$n:String!,$p:[String]!,$r:[PageRuleInput]!){groups{update('
'id:$id,name:$n,redirectOnLogin:"/",permissions:$p,pageRules:$r){responseResult{succeeded message}}}}',
jwt, {"id": gid, "n": name, "p": perms, "r": rules},
)
log(f"Gruppe '{name}' -> Rechte+Seitenregeln gesetzt")
def rule(rid: str, deny: bool, perms: list, path: str, match: str = "START"):
return {"id": rid, "deny": deny, "match": match, "roles": perms,
"path": path, "locales": []}
def main():
log(f"Ziel: {WIKI}")
wait_reachable()
if in_setup_mode():
finalize()
jwt = login()
if jwt is None:
log("lokaler Login nicht möglich (local wurde deaktiviert) — kann nicht konfigurieren, Ende.")
return
log("eingeloggt")
# Erst Gruppen (Schreibvorgänge), DANN OIDC — ensure_oidc deaktiviert zuletzt local.
# authentik Admins: alles lesen+schreiben. wiki-anwender: nur /anwender lesen.
ensure_group(jwt, "authentik Admins", ADMIN_PERMS, [rule("adm", False, ["read:pages", "write:pages", "manage:pages"], "")])
# wiki-anwender: nur den Anwender-Bereich lesen + die Startseite (sonst wäre "/"
# für sie gesperrt). betrieb/* matcht keine Regel -> Default-Deny (Abschottung).
ensure_group(jwt, "wiki-anwender", READER_PERMS, [
rule("anw", False, READER_PERMS, "anwender"),
rule("anwhome", False, READER_PERMS, "home", match="EXACT"),
])
# Guests (id 2) alle Rechte entziehen — Login-Pflicht, keine öffentliche Sicht.
gql('mutation{groups{update(id:2,name:"Guests",redirectOnLogin:"/",permissions:[],pageRules:[]){responseResult{succeeded}}}}', jwt)
ensure_oidc(jwt)
set_hide_local(jwt)
ensure_theming(jwt)
ensure_locale(jwt) # Deutsch als Default + Inhalte en->de migrieren (vor der Nav)
set_navigation(jwt)
ensure_html_rendering(jwt)
ensure_git_storage(jwt)
ensure_timezones(jwt) # Systemkonten auf Europe/Berlin
log("fertig — Wiki.js konfiguriert")
if __name__ == "__main__":
main()