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) orinternal(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:
- Name:
site_nameinconfig.jsonor the "Design & Branding" section in/settings. - Logo & favicon:
static/logo.png(sidebar) andstatic/favicon.png(browser tab) are the shipped example images and the fallbacks used whenlogo_url/favicon_urlare empty. Upload your own via/settings(they land instatic/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. - Theme:
default-green(#006C66),classic-blue(#0b3c5d), orcustomwith a free hex color; defined intemplates/base.html(CSS variables) and offered in/settings. - Header links:
wiki_data/header_links.json(example content) or the header-links box in/settings. - Start page & imprint:
wiki_data/pages/home/andwiki_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 byldap_enabledinconfig.json:- Local (default): users in
wiki_data/users.json(bcrypt hash per user). Theadminuser is always an admin, everyone else is an "editor". - LDAP: see its own section below.
- Local (default): users in
- Role per session:
adminoreditor. 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:localhostcan resolve to::1instead of127.0.0.1, and reaching the server via its DNS name usually means its LAN address rather than a loopback one. The example config allowlists127.0.0.1,::1and10.0.0.0/8. - Pages with
access: internalare only visible to logged-in users or clients from the allowlist; this automatically applies to all subpages too. - Pages with
draft: trueare 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" writesdraft: trueinto 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).isPageDraftinpages.goadditionally 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 toexternalleaves its subpagesinternal. homeandimpressumcan never be drafts./redirects tohome, 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.jsonthe flag only appears when it is set ("draft": true). Pages created before this feature existed have nodraftkey 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
maxHistoryEntriesconstant inhistory.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 (
newPasswordHashinauth.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, thesaltfield disappears fromusers.jsonandhashPasswordcan go. Thepasswordfield inconfig.jsononly matters for creating the initialadminuser — it should be removed from the file afterwards. wiki_data/config.json,users.json, androles.jsonare deliberately.gitignore'd (plaintext passwords/secrets, TLS passphrase, hashes) — they must be created manually on a fresh install, agit clonealone 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
opensslsubprocess 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_keyconfig 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 intostatic/vendor/(see below). go-ldapis deliberately pinned to v3.4.10 (go.mod): newer versions raise the module's minimum Go version above 1.21, whichgo mod tidywould otherwise pull intogo.modautomatically. 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/holdsquill.js+quill.snow.css(v2.0.2) andquill-table-better.js+quill-table-better.css(v1.2.3), referenced fromtemplates/edit.htmlwith the version as a cache-busting query. Nothing there is generated — the files are the unmodifieddist/builds from npm, with one exception: the//# sourceMappingURL=quill.js.mapcomment at the end ofquill.jswas 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-facerules at the top ofstatic/css/styles.css, instead of the former@importfromfonts.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. Theunicode-rangerules 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 intostatic/vendor/, drop the sourcemap comment again, and bump the?v=intemplates/edit.html. Note thatquill-table-betterused 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)
Search
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.