From b7ba74834e0f5277518885b29ba07407911fb3be Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 26 Aug 2026 14:23:28 +0200 Subject: [PATCH] feat: self-hosted Wiki.js for this repository, no login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloning this repository got you Markdown files. tools/serve.py renders them, but it is a preview shim rather than the software the content is published into, so there was no way to see how a page will actually look — or to browse the wiki offline. docker compose up -d → http://localhost:3000 Wiki.js 2.5 with PostgreSQL, serving this repository's pages, readable immediately with no login. Wiki.js normally needs a browser setup wizard, an admin account and a storage target configured by hand before it shows a single page. wikijs/bootstrap.py does all three over Wiki.js's own APIs: it completes the wizard, points the Local File System target at the mounted repository, imports every page, drops README (which documents the repository, not the project) and confirms guests can read anonymously. It is idempotent, so re-running only refreshes content and repairs drifted configuration. The checkout is mounted read-only and the disk target is disabled again after importing, so Wiki.js never writes back into anyone's working tree. tools/check_wikijs.py fails when a page is missing, returns a non-200, or bounces an anonymous visitor to a login screen. It deliberately does not follow redirects: Wiki.js answers an unauthorised guest with a 302 to /login, and that page returns 200 carrying the site title, so following redirects would let a locked-down wiki pass. CI runs it against a real stack. Closes #3 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YY1ekLLeFLkAU2kvdQ8Ey4 --- .github/workflows/check-wiki.yml | 35 +++- README.md | 76 +++++++- compose.yaml | 66 +++++++ tools/check_wikijs.py | 138 +++++++++++++ wikijs/bootstrap.py | 321 +++++++++++++++++++++++++++++++ 5 files changed, 628 insertions(+), 8 deletions(-) create mode 100644 compose.yaml create mode 100755 tools/check_wikijs.py create mode 100755 wikijs/bootstrap.py diff --git a/.github/workflows/check-wiki.yml b/.github/workflows/check-wiki.yml index 3335fdd..81cd997 100644 --- a/.github/workflows/check-wiki.yml +++ b/.github/workflows/check-wiki.yml @@ -1,4 +1,4 @@ -name: Check wiki matches the API +name: Check wiki on: push: @@ -7,8 +7,8 @@ on: workflow_dispatch: jobs: - check: - name: Wiki drift check + drift: + name: Wiki matches the API runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -19,3 +19,32 @@ jobs: - name: Check every endpoint is documented and every documented path exists run: python3 tools/check_wiki.py + + wikijs: + name: Wiki.js stack serves the pages with no login + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Start Wiki.js and PostgreSQL + run: docker compose up -d db wiki + + # Runs in the foreground so a bootstrap failure fails the job here, + # rather than showing up later as an empty wiki. + - name: Bootstrap + run: docker compose run --rm bootstrap + + - name: Check every page is served anonymously + run: python3 tools/check_wikijs.py + + - name: Logs + if: failure() + run: docker compose logs --no-color + + - name: Tear down + if: always() + run: docker compose down -v diff --git a/README.md b/README.md index 7416a05..8822d03 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,70 @@ Markdown with Wiki.js front matter and are rendered at | `Configuration.md` | Every setting and where it can come from | | `Deployment.md` | Production deployment | -## Reading it locally +## Running the wiki locally + +```bash +docker compose up -d +``` + +Then open **http://localhost:3000**. That is a self-hosted [Wiki.js](https://js.wiki) +serving this repository's pages — the same software the published wiki runs on, so a page +looks here exactly as it will look once published. + +**No login.** The setup wizard is completed for you and guests can read every page +anonymously. There is nothing to click through and no account to create. + +Port 3000 taken? + +```bash +WIKI_PORT=3030 docker compose up -d +``` + +### What comes up + +| Service | Role | +|---|---| +| `wiki` | Wiki.js 2.5 | +| `db` | PostgreSQL 16, its backing store | +| `bootstrap` | Runs once and exits — completes setup, imports the pages, confirms guest read | + +Your checkout is mounted **read-only**, so Wiki.js can import from it but never writes back +and never dirties your working tree. + +An admin account exists because Wiki.js requires one +(`admin@opentaberna.local` / `opentaberna-local-admin`), but nothing asks you to use it. +It is a local development stack on a throwaway database — do not expose it to a network. + +### Refreshing after an edit + +Pages are imported into Wiki.js, so editing a `.md` file does not change what is served +until you re-import: + +```bash +docker compose run --rm --no-deps bootstrap +``` + +That is idempotent — it re-imports the content and repairs the configuration if anything +has drifted. + +### Starting over + +```bash +docker compose down -v # -v drops the database, so setup runs again +``` + +## Quick preview without Docker ```bash python3 tools/serve.py # http://localhost:8090 ``` -Renders the Markdown with Mermaid diagrams and working internal links, so a change can be -checked before it is published. Standard library only — no install step. Marked and -Mermaid load from a CDN, so the first page view needs a network connection. +A lighter alternative when you only want to eyeball a change: it renders the Markdown with +Mermaid diagrams and working internal links. Standard library only, no install step. Marked +and Mermaid load from a CDN, so the first page view needs a network connection. + +It is a preview shim, not Wiki.js — use `docker compose up -d` when it matters how the page +will really look. ## Keeping it honest @@ -44,7 +99,18 @@ It fails when 2. a page documents a path the API does not serve, or 3. an internal link points at a page that does not exist. -CI runs it on every push and pull request. +`tools/check_wikijs.py` covers the other half — that the stack above still serves every page +without a login: + +```bash +docker compose up -d +python3 tools/check_wikijs.py +``` + +It fails when a page is missing, returns a non-200, or bounces an anonymous visitor to the +login screen. + +CI runs both on every push and pull request. ### When the API changes diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..def6608 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,66 @@ +# Self-hosted Wiki.js serving this repository's pages. +# +# docker compose up -d → http://localhost:3000 +# +# No login: the setup wizard is completed automatically and guests can read +# every page anonymously. See README.md. +# +# Port 3000 already taken? WIKI_PORT=3030 docker compose up -d + +name: opentaberna-wiki + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: wiki + POSTGRES_USER: wiki + # Local-only database, never exposed outside the compose network. + POSTGRES_PASSWORD: wiki + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wiki -d wiki"] + interval: 5s + timeout: 5s + retries: 20 + restart: unless-stopped + + wiki: + image: ghcr.io/requarks/wiki:2.5 + depends_on: + db: + condition: service_healthy + environment: + DB_TYPE: postgres + DB_HOST: db + DB_PORT: 5432 + DB_NAME: wiki + DB_USER: wiki + DB_PASS: wiki + volumes: + # The repository itself, read-only. Wiki.js imports the Markdown from + # here; it never writes back, so your checkout stays clean. + - .:/wiki-content:ro + ports: + - "${WIKI_PORT:-3000}:3000" + restart: unless-stopped + + # Runs once, then exits. Completes the setup wizard, imports the pages and + # confirms guests can read them. Safe to re-run — see "Refreshing" in README. + bootstrap: + image: python:3.13-alpine + depends_on: + - wiki + environment: + WIKI_URL: http://wiki:3000 + WIKI_CONTENT_PATH: /wiki-content + ADMIN_EMAIL: admin@opentaberna.local + ADMIN_PASSWORD: opentaberna-local-admin + volumes: + - ./wikijs/bootstrap.py:/bootstrap.py:ro + command: ["python3", "/bootstrap.py"] + restart: "no" + +volumes: + db_data: diff --git a/tools/check_wikijs.py b/tools/check_wikijs.py new file mode 100755 index 0000000..88e8dca --- /dev/null +++ b/tools/check_wikijs.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +Check the Wiki.js stack serves this repository's pages with no login. + +Assumes the stack is already up: + + docker compose up -d + python3 tools/check_wikijs.py + +Fails when + +1. a page in the repository is missing from Wiki.js, +2. a page does not return 200 to an anonymous request, +3. a page's own title is absent from what is served, or +4. anonymous access is bounced to a login screen. + +Point 4 is the one that guards "no auth". Wiki.js answers a page a guest may not +read with the login screen under a 200, so status alone proves nothing — the +title has to actually be in the body. + +Override the base URL with WIKI_URL when not on the default port: + + WIKI_PORT=3030 docker compose up -d + WIKI_URL=http://localhost:3030 python3 tools/check_wikijs.py +""" + +from __future__ import annotations + +import html +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +WIKI_URL = os.environ.get("WIKI_URL", "http://localhost:3000").rstrip("/") + +# Present in the repository, deliberately not published as a wiki page. +NOT_WIKI_PAGES = {"README"} + +LOGIN_MARKERS = ("login-container", "loginBgUrl", "Sign In") + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + """ + Refuse to follow redirects. + + Wiki.js bounces a guest who may not read a page to /login with a 302. Left + to follow it, urllib lands on the login page, which returns 200 and carries + the site title — so a redirect would read as a pass. A redirect is the + failure, so it has to be seen rather than followed. + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +OPENER = urllib.request.build_opener(NoRedirect) + + +def pages() -> list[tuple[str, str]]: + """(wiki path, title) for every page the repository publishes.""" + found = [] + for path in sorted(ROOT.rglob("*.md")): + if ".git" in path.parts: + continue + name = path.relative_to(ROOT).with_suffix("").as_posix() + if name in NOT_WIKI_PAGES: + continue + title = "" + text = path.read_text(encoding="utf-8") + match = re.match(r"^---\n(.*?)\n---\n", text, re.S) + if match: + for line in match.group(1).splitlines(): + if line.startswith("title:"): + title = line.split(":", 1)[1].strip() + break + found.append((name, title)) + return found + + +def fetch(path: str) -> tuple[int, str]: + try: + with OPENER.open(f"{WIKI_URL}/{path}", timeout=30) as response: + return response.status, response.read().decode("utf-8", "replace") + except urllib.error.HTTPError as exc: + return exc.code, "" + except (urllib.error.URLError, OSError) as exc: + sys.exit( + f"Cannot reach {WIKI_URL} ({exc}).\n" + f"Is the stack up? docker compose up -d" + ) + + +def main() -> int: + expected = pages() + if not expected: + sys.exit("No wiki pages found in the repository.") + + failures: list[str] = [] + + for path, title in expected: + status, body = fetch(path) + + if status in (301, 302, 303, 307, 308): + failures.append( + f"/{path}: HTTP {status} — redirected away, guests cannot read it" + ) + continue + + if status != 200: + failures.append(f"/{path}: HTTP {status}") + continue + + if any(marker in body for marker in LOGIN_MARKERS): + failures.append(f"/{path}: served a login screen — guests cannot read it") + continue + + if title and title not in body and html.escape(title) not in body: + failures.append(f"/{path}: served 200 but the title {title!r} is missing") + + print(f"Checked {len(expected)} pages against {WIKI_URL}.") + + if failures: + print(f"\n{len(failures)} problem(s):\n") + for failure in failures: + print(f" FAIL {failure}") + print("\nThe Wiki.js stack is not serving this repository anonymously.") + return 1 + + print("OK — every page is served, and no page asks for a login.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/wikijs/bootstrap.py b/wikijs/bootstrap.py new file mode 100755 index 0000000..23d296d --- /dev/null +++ b/wikijs/bootstrap.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Bring a fresh Wiki.js up preconfigured against this repository. + +Wiki.js normally needs a browser-driven setup wizard, an admin account and a +storage target configured by hand before it shows a single page. That is three +manual steps between cloning this repository and reading it, so this script does +them over Wiki.js's own APIs instead: + +1. Completes the setup wizard (POST /finalize) if it has not run yet. +2. Sets the site title and turns off comments and ratings — this is a docs + mirror, not a forum. +3. Points the Local File System storage target at the mounted repository and + imports every Markdown page. +4. Removes README, which documents the repository rather than the project. +5. Verifies guests can read without logging in, and grants it if not. + +Every step is idempotent, so re-running only refreshes the content. + +Standard library only, matching tools/check_wiki.py — the image this runs in is +plain python:alpine with nothing installed. +""" + +from __future__ import annotations + +import json +import os +import sys +import time +import urllib.error +import urllib.request + +WIKI_URL = os.environ.get("WIKI_URL", "http://wiki:3000").rstrip("/") +CONTENT_PATH = os.environ.get("WIKI_CONTENT_PATH", "/wiki-content") +ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "admin@opentaberna.local") +ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "opentaberna-local-admin") + +# Pages that exist in the repository but are not wiki content. +NOT_WIKI_PAGES = {"README"} + +GUEST_GROUP_ID = 2 +READ_PERMISSIONS = ["read:pages", "read:assets", "read:comments"] + + +def log(message: str) -> None: + print(f"[bootstrap] {message}", flush=True) + + +def post(path: str, payload: dict, token: str | None = None) -> dict: + request = urllib.request.Request( + f"{WIKI_URL}{path}", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + if token: + request.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(request, timeout=120) as response: + return json.load(response) + + +def graphql(query: str, token: str, variables: dict | None = None) -> dict: + body = post("/graphql", {"query": query, "variables": variables or {}}, token) + if "errors" in body: + raise RuntimeError(f"GraphQL error: {body['errors']}") + return body["data"] + + +def wait_for_http(timeout: int = 300) -> None: + log(f"waiting for {WIKI_URL}") + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(WIKI_URL, timeout=5) as response: + if response.status == 200: + log("Wiki.js is responding") + return + except (urllib.error.URLError, OSError): + pass + time.sleep(2) + sys.exit(f"Wiki.js did not respond within {timeout}s") + + +def login() -> str | None: + query = """ + mutation($u: String!, $p: String!) { + authentication { + login(username: $u, password: $p, strategy: "local") { + responseResult { succeeded message } + jwt + } + } + } + """ + try: + body = post( + "/graphql", + {"query": query, "variables": {"u": ADMIN_EMAIL, "p": ADMIN_PASSWORD}}, + ) + except (urllib.error.HTTPError, urllib.error.URLError): + return None + if "errors" in body: + return None + result = body.get("data", {}).get("authentication", {}).get("login") + if result and result["responseResult"]["succeeded"]: + return result["jwt"] + return None + + +def run_setup_wizard() -> None: + log("running the setup wizard") + body = post( + "/finalize", + { + "adminEmail": ADMIN_EMAIL, + "adminPassword": ADMIN_PASSWORD, + "adminPasswordConfirm": ADMIN_PASSWORD, + "siteUrl": WIKI_URL, + "telemetry": False, + }, + ) + if not body.get("ok"): + sys.exit(f"setup failed: {body}") + log("setup complete, waiting for Wiki.js to restart") + + +def authenticate() -> str: + token = login() + if token: + log("already set up") + return token + + run_setup_wizard() + + deadline = time.time() + 300 + while time.time() < deadline: + time.sleep(3) + token = login() + if token: + log("signed in") + return token + sys.exit("could not sign in after setup") + + +def configure_site(token: str) -> None: + query = """ + mutation($host: String!, $title: String!, $descr: String!) { + site { + updateConfig( + host: $host, title: $title, description: $descr, + robots: [], analyticsService: "", analyticsId: "", company: "", + contentLicense: "", footerOverride: "", logoUrl: "", + pageExtensions: "md, html, txt", + authAutoLogin: false, authEnforce2FA: false, authHideLocal: false, + authLoginBgUrl: "", authJwtAudience: "urn:wiki.js", + authJwtExpiration: "30m", authJwtRenewablePeriod: "14d", + editFab: true, editMenuBar: false, editMenuBtn: true, + editMenuExternalBtn: false, editMenuExternalName: "", + editMenuExternalIcon: "", editMenuExternalUrl: "", + featurePageRatings: false, featurePageComments: false, + featurePersonalWikis: false, + securityOpenRedirect: true, securityIframe: true, + securityReferrerPolicy: true, securityTrustProxy: true, + securitySRI: true, securityHSTS: false, securityHSTSDuration: 300, + securityCSP: false, securityCSPDirectives: "" + ) { responseResult { succeeded message } } + } + } + """ + result = graphql( + query, + token, + { + "host": WIKI_URL, + "title": "OpenTaberna Wiki", + "descr": "Documentation for the OpenTaberna project", + }, + )["site"]["updateConfig"]["responseResult"] + if not result["succeeded"]: + sys.exit(f"could not configure the site: {result['message']}") + log("site configured") + + +def set_disk_target(token: str, enabled: bool) -> None: + query = """ + mutation($targets: [StorageTargetInput]!) { + storage { + updateTargets(targets: $targets) { + responseResult { succeeded message } + } + } + } + """ + targets = [ + { + "isEnabled": enabled, + "key": "disk", + "mode": "push", + "syncInterval": "P0D", + "config": [ + {"key": "path", "value": json.dumps({"v": CONTENT_PATH})}, + {"key": "createDailyBackups", "value": json.dumps({"v": False})}, + ], + } + ] + result = graphql(query, token, {"targets": targets})["storage"]["updateTargets"][ + "responseResult" + ] + if not result["succeeded"]: + sys.exit(f"could not update the storage target: {result['message']}") + + +def import_content(token: str) -> None: + log(f"importing pages from {CONTENT_PATH}") + set_disk_target(token, enabled=True) + + query = """ + mutation { + storage { + executeAction(targetKey: "disk", handler: "importAll") { + responseResult { succeeded message } + } + } + } + """ + result = graphql(query, token)["storage"]["executeAction"]["responseResult"] + if not result["succeeded"]: + sys.exit(f"import failed: {result['message']}") + + # Disable it again. The target is a "push" target, so leaving it on would + # have Wiki.js write edits back out to a read-only mount and log errors. + # Importing is all we want from it. + set_disk_target(token, enabled=False) + log("import complete") + + +def list_pages(token: str) -> list[dict]: + return graphql("{ pages { list { id path title } } }", token)["pages"]["list"] + + +def remove_non_wiki_pages(token: str) -> None: + for page in list_pages(token): + if page["path"] in NOT_WIKI_PAGES: + graphql( + "mutation($id: Int!) { pages { delete(id: $id) " + "{ responseResult { succeeded message } } } }", + token, + {"id": page["id"]}, + ) + log(f"removed non-wiki page: {page['path']}") + + +def ensure_guest_read(token: str) -> None: + group = graphql( + """ + query($id: Int!) { + groups { single(id: $id) { + id name redirectOnLogin permissions + pageRules { id deny match roles path locales } + } } + } + """, + token, + {"id": GUEST_GROUP_ID}, + )["groups"]["single"] + + if all(p in group["permissions"] for p in READ_PERMISSIONS): + log("guests can read without signing in") + return + + log("granting guests read access") + graphql( + """ + mutation($id: Int!, $name: String!, $redirect: String!, + $perms: [String]!, $rules: [PageRuleInput]!) { + groups { + update(id: $id, name: $name, redirectOnLogin: $redirect, + permissions: $perms, pageRules: $rules) { + responseResult { succeeded message } + } + } + } + """, + token, + { + "id": group["id"], + "name": group["name"], + "redirect": group.get("redirectOnLogin") or "/", + "perms": READ_PERMISSIONS, + "rules": [ + { + "id": "guest", + "deny": False, + "match": "START", + "roles": READ_PERMISSIONS, + "path": "", + "locales": [], + } + ], + }, + ) + + +def main() -> int: + wait_for_http() + token = authenticate() + configure_site(token) + import_content(token) + remove_non_wiki_pages(token) + ensure_guest_read(token) + + pages = list_pages(token) + log(f"{len(pages)} pages available:") + for page in sorted(pages, key=lambda p: p["path"]): + log(f" /{page['path']} — {page['title']}") + log("ready — open the wiki, no login required") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())