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>
466 lines
24 KiB
Python
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()
|