2026-07-29 17:21:21 +02:00
2026-07-29 17:21:21 +02:00

Wiki

Bilingual (DE/EN) wiki engine. Self-contained Go application, no database server needed — pages, users, and configuration live as JSON/HTML files on disk under wiki_data/.

This is a content-free template: the application, templates, themes and example logos are complete, wiki_data/ only contains a placeholder start page and imprint page. Branding (wiki name, logo, favicon, colors, header links) is configured at runtime via /settings, no template editing needed.

This README only describes the application itself (features, installation, configuration). For the wiki's content, the wiki itself is the reference.

Features

  • Bilingual pages (DE/EN) with a hierarchical structure up to 4 levels deep (top-level page → subpage → sub-subpage → sub-sub-subpage), an automatically built sidebar navigation, and full-text search.
  • WYSIWYG editor (Quill.js) for editing pages directly in the browser, including per-page image/file uploads.
  • Access control per page: external (visible to anyone with IP clearance) or internal (requires login or an allowed network) — automatically inherited by subpages.
  • Drafts: a page can be saved without being published — invisible to readers until it is released, editable in the meantime.
  • Per-page version history (/history/<slug>): view past versions and restore them with one click, with an automatic safety snapshot before every restore. No external git-autocommit needed.
  • Login with two modes: local user accounts (password hashes) or LDAP authentication with locally managed roles (editor/admin) and an optional live LDAP user search for role assignment.
  • Settings UI (admin only) for the IP allowlist, branding (wiki name/theme/color/logo/favicon), header links, network ports, TLS certificate, and the entire login configuration — no manual file editing required.
  • CSRF protection, IP-based rate limiting on failed logins, and logging of every login attempt (success/failure/rate-limited) with username and IP.

Installation

Requirements:

Dependency What for Package name
Go 1.21+ Compiler/toolchain, the only hard requirement golang-go (Debian/Ubuntu), golang (Fedora/Arch), go (macOS/brew), or go.dev/dl
openssl (optional) Only if tls_key_passphrase is set, to decrypt the TLS key usually already installed

All Go dependencies (chi, pongo2, go-ldap) are fetched automatically by go build from the module cache — no separate step needed. The WYSIWYG editor and the webfonts ship with the repository under static/vendor/, so neither the server nor the browser needs internet access at runtime — the application makes no third-party requests at all.

First-time setup:

# Create the config from the template
cp wiki_data/config.json.example wiki_data/config.json
# adjust at least allowed_ips, site_name and password in config.json

go build -o wiki .
./wiki

Do not copy users.json from its template — it's created automatically on first start with an admin user (password taken from config.json's password field, which is emptied again right afterwards). roles.json is only needed if LDAP login is enabled (see below).

The application must be started from the repo root (it expects wiki_data/, templates/, static/ relative to the working directory). Without a valid TLS certificate, only the HTTP server runs; everything else (ports, TLS, theme, ...) can be configured afterwards via /settings.

Restarting after code changes:

go build -o wiki .
kill -TERM <old-pid>   # stops the server gracefully
./wiki > server.log 2>&1 &!

Plain content changes under wiki_data/pages/ (e.g. via the editor) need no restart. Only search doesn't automatically pick up direct filesystem changes made without the editor.

Configuration (wiki_data/config.json)

Field Meaning
allowed_ips List of allowed IPs/CIDR ranges for anonymous read access & login
site_name Name of the wiki, shown in the browser title and the footer (default Wiki)
password Password for the initially created admin user. Used once on the very first start and then cleared from the file automatically
http_port / https_port Ports for the HTTP and HTTPS servers
tls_cert_path / tls_key_path / tls_key_passphrase TLS certificate for HTTPS (optional, an encrypted key is decrypted via openssl). Renewed certificates are picked up automatically, see below
redirect_http Automatically redirect HTTP requests to HTTPS
theme, custom_color UI color scheme (default-green, classic-blue, or custom + a hex color)
logo_url, favicon_url Sidebar logo and browser favicon (default /static/logo.png and /static/favicon.png)
ldap_* See the LDAP Authentication section

All fields can also be edited via /settings (admin only) without touching the file by hand — that writes straight to config.json and restarts the servers if needed.

Branding a new wiki

Everything institution-specific is data, not code — a fresh deployment only needs:

  1. Name: site_name in config.json or the "Design & Branding" section in /settings.
  2. Logo & favicon: static/logo.png (sidebar) and static/favicon.png (browser tab) are the shipped example images and the fallbacks used when logo_url/favicon_url are empty. Upload your own via /settings (they land in static/ and can be picked from the gallery there), or simply replace the two files. The two defaults are protected against deletion in the settings UI.
  3. Theme: default-green (#006C66), classic-blue (#0b3c5d), or custom with a free hex color; defined in templates/base.html (CSS variables) and offered in /settings.
  4. Header links: wiki_data/header_links.json (example content) or the header-links box in /settings.
  5. Start page & imprint: wiki_data/pages/home/ and wiki_data/pages/impressum/ contain placeholder text — edit them in the browser. Both slugs are special-cased (never listed in the sidebar, cannot be deleted), so they should exist.

Authentication & Access Control

  • Single login form (/login). Two modes, controlled by ldap_enabled in config.json:
    • Local (default): users in wiki_data/users.json (bcrypt hash per user). The admin user is always an admin, everyone else is an "editor".
    • LDAP: see its own section below.
  • Role per session: admin or editor. Admin-only areas (/settings, role/user management, deleting the logo) check the role server-side.
  • Sessions live only in memory (no session secret in a file) — restarting the application logs everyone out.
  • IP allowlist (allowed_ips): controls who can read without logging in, and who can log in at all. The "Editor Login" button is hidden for clients outside the list — so if the login button is missing, the client IP is the first thing to check. It's shown in the page footer. Watch out for the address the client actually arrives from: localhost can resolve to ::1 instead of 127.0.0.1, and reaching the server via its DNS name usually means its LAN address rather than a loopback one. The example config allowlists 127.0.0.1, ::1 and 10.0.0.0/8.
  • Pages with access: internal are only visible to logged-in users or clients from the allowlist; this automatically applies to all subpages too.
  • Pages with draft: true are unpublished, see the next section.
  • CSRF protection on every state-changing request, IP-based rate limiting on failed logins, logging of every login attempt with username and IP.

LDAP Authentication

When ldap_enabled: true is set, LDAP fully replaces local password checking — there's no fallback to local accounts, not even for admin. The login binds directly as the logging-in user (no service/reader account needed): the username is validated, escaped, and inserted into ldap_user_dn_template (e.g. "uid=%s,ou=people,dc=example,dc=org"); the bind with the entered password is the check.

LDAP here only confirms whether the password is correct — it has no concept of roles for this wiki. Who's even allowed to log in, and with which role (editor/admin), is therefore tracked locally in wiki_data/roles.json, managed via the /users page. "Revoke access" only deletes the local entry, never the LDAP account itself; the last remaining admin entry cannot be removed.

Optionally, the /users page can also search LDAP live for users (ldap_search_bind_dn/ldap_search_bind_password/ldap_search_base_dn), so a role never gets assigned to a username that doesn't actually exist — this binds with a separate reader account, since the admin doesn't re-enter their own password here. Without these fields, the form falls back to a plain text field.

Relevant config fields: ldap_enabled, ldap_host, ldap_port, ldap_use_ssl (LDAPS), ldap_start_tls (only enable if the server actually supports it), ldap_user_dn_template, ldap_search_bind_dn, ldap_search_bind_password, ldap_search_base_dn — see wiki_data/config.json.example. Template for the role list: wiki_data/roles.json.example.

Drafts (save without publishing)

Every page has a "Publication" setting in the editor: Published or Draft. A draft is saved completely normally — content, media, version history — but stays invisible to readers:

  • it is not in the sidebar navigation and not in the search results,
  • calling its URL directly returns a plain 404 (the same answer as for a page that does not exist, so an in-progress slug cannot be found by guessing),
  • its images and attachments under /images/<slug>/… and /files/<slug>/… return 404 as well.

Logged-in editors see the page normally, marked (Entwurf)/(draft) in the sidebar and with a notice above the content. Switching the setting to "Published" releases it — no separate publishing step, no second copy of the content.

Details worth knowing:

  • A brand-new page starts as a draft. Creating a page therefore never puts an unfinished text online; publishing is one deliberate click in the editor.
  • Drafts are gated on being logged in, not on IP. Unlike access: internal (an IP rule for readers), a visitor from an allowlisted network is still a reader and does not see drafts. Whoever may edit, may see them.
  • Subpages of a draft are drafts too — exactly like internal, in both directions: setting a page to "Draft" writes draft: true into every subpage below it, and a subpage of a draft cannot be published on its own (the editor locks the field and the server enforces it). isPageDraft in pages.go additionally walks up the slug hierarchy, so a page below a draft is never reachable even if its own metadata says otherwise. Releasing the subtree again is deliberate, one page at a time — publishing the parent does not automatically publish everything below it, mirroring how a page turned back to external leaves its subpages internal.
  • home and impressum can never be drafts. / redirects to home, so an unpublished start page would 404 the entire wiki for readers; the option is hidden in the editor and rejected server-side.
  • In metadata.json the flag only appears when it is set ("draft": true). Pages created before this feature existed have no draft key and count as published.

Version History

The "History" button on every page (/history/<slug>) lets you view and restore past versions:

  • Before every content change, the previous version is automatically saved to wiki_data/history/<slug>/<timestamp>/.
  • The list shows every saved version (timestamp, who saved it); "Preview" shows an old version read-only, without changing the current page.
  • "Restore" overwrites the current page — but snapshots the current state first, so a restore is never a loss.
  • Each page keeps at most 10 versions (purely count-based, no time limit); older ones are pruned automatically. Adjustable via the maxHistoryEntries constant in history.go (rebuild afterwards). wiki_data/history/ is .gitignore'd.
  • There's deliberately no line-level diff view: page content is Quill-rendered HTML rather than plain-text markup, so a line-by-line diff would be mostly tag noise rather than a useful comparison.

Security

  • IP detection comes exclusively from the actual TCP connection, never from X-Forwarded-For (the app terminates TLS itself, with no reverse proxy in front of it).
  • Passwords are hashed with bcrypt (newPasswordHash in auth.go), never stored in plaintext. Accounts created before the switch used a salted SHA-256 hash; those still verify, and the stored hash is transparently replaced with a bcrypt one on the next successful login — the only moment the plaintext is available. Once every account has logged in once, the salt field disappears from users.json and hashPassword can go. The password field in config.json only matters for creating the initial admin user — it should be removed from the file afterwards.
  • wiki_data/config.json, users.json, and roles.json are deliberately .gitignore'd (plaintext passwords/secrets, TLS passphrase, hashes) — they must be created manually on a fresh install, a git clone alone isn't enough.
  • Login redirects (the next= parameter) are restricted to same-origin paths, to prevent open-redirect attacks right after login.

TLS certificate renewal

With tls_cert_path/tls_key_path set, the certificate is served through tls.Config.GetCertificate (certReloader in server.go) instead of being pinned into the server at startup. Replacing both files in place — same paths, same names — is enough: the next TLS handshake picks up the new certificate, with no restart and no dropped connections. Suitable for automatically renewed certificates (Let's Encrypt and friends).

  • The files are only re-read when their modification time or size changed, not on every handshake — this also keeps the openssl subprocess for encrypted keys (tls_key_passphrase) out of the handshake path.
  • Cert and key are swapped as a pair. If a handshake happens to fall between the two cps, the mismatched pair fails to parse, the previously loaded certificate keeps being served, and the reason is logged once. The next change to either file is retried normally, so the newly renewed pair takes effect as soon as both files are in place.
  • The deprecated inline tls_cert/tls_key config fields have no file to watch and stay static — they only take effect on restart.

Architecture

  • Language: Go 1.21, Router: chi, Templating: pongo2 (Django-style syntax), Editor: Quill.js v2 + quill-table-better, both vendored into static/vendor/ (see below).
  • go-ldap is deliberately pinned to v3.4.10 (go.mod): newer versions raise the module's minimum Go version above 1.21, which go mod tidy would otherwise pull into go.mod automatically. Check whether that's still wanted before bumping the dependency.
  • The editor's JavaScript and CSS are vendored, not loaded from a CDN: static/vendor/ holds quill.js + quill.snow.css (v2.0.2) and quill-table-better.js + quill-table-better.css (v1.2.3), referenced from templates/edit.html with the version as a cache-busting query. Nothing there is generated — the files are the unmodified dist/ builds from npm, with one exception: the //# sourceMappingURL=quill.js.map comment at the end of quill.js was removed, because the 0.8 MB map is not shipped and the browser would request it in vain whenever the devtools are open.
  • The webfonts (Outfit and Plus Jakarta Sans) live in static/vendor/fonts/ with their @font-face rules at the top of static/css/styles.css, instead of the former @import from fonts.googleapis.com. That import reported every visitor's IP to Google on every page view, which is hard to justify for a wiki that carries its own privacy page. They are the variable font files covering weight 300-700 in a single file per subset; the non-standard weights 550 and 650 used in the stylesheet only render exactly because of that. The unicode-range rules are kept as served, so a browser still downloads only the subset it needs (116 KB in total, of which a German or English page uses about 58 KB).
  • To update the editor, download the new dist/ files into static/vendor/, drop the sourcemap comment again, and bump the ?v= in templates/edit.html. Note that quill-table-better used to be pulled unpinned (always latest) — pinning it is the point of vendoring, so an editor that misbehaves after an update is now traceable to a deliberate version bump.
main.go          Startup, shutdown, buildRouter()
routes_static.go   Static assets and user-uploaded media
routes_pages.go    Reading pages (/, /page/*, language switch)
routes_auth.go     Login, logout, user and role management
routes_settings.go The admin-only settings screen
routes_editing.go  Creating, editing, moving, deleting, history, search
routes_uploads.go  Per-page image and file uploads
pages.go         Page CRUD, slug handling, search, alert-box processing
history.go       Per-page version history: snapshots, preview, restore
auth.go          Login/logout, sessions, user management, CSRF
ldap.go          LDAP bind (login) and LDAP search (role assignment)
roles.go         Local role assignment for LDAP users
config.go        Load/cache config.json, header links, color/logo helpers
template.go      Sidebar tree, shared template context
server.go        HTTP/HTTPS server startup, TLS (incl. certificate hot reload), graceful restart

templates/       Pongo2 HTML templates
static/          CSS/JS/logos (served statically under /static/*)

wiki_data/
  config.json        Runtime configuration (.gitignore'd)
  users.json         Local users, password hashes (.gitignore'd)
  roles.json         LDAP mode: who may log in as editor/admin (.gitignore'd)
  header_links.json  External links in the header
  pages/<slug>/      de.html, en.html, metadata.json, images/, files/
                     (ships with placeholders for `home` and `impressum`)
  history/<slug>/    Past versions of a page (.gitignore'd)

A simple in-memory search index is built from all pages at server startup and updated incrementally whenever a page is saved through the application. Direct filesystem changes (without going through the editor/API) do not update this index — after larger manual interventions, a restart is recommended to bring search back in sync.

Tests

go test ./...

Unit tests cover, among other things, slug validation, media path rewriting when moving pages, alert-box processing, and secureFilename.

S
Description
general wiki in go with html, css and js
Readme 475 KiB
Languages
Go 39.5%
HTML 31.8%
JavaScript 14.8%
CSS 13.9%