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())