Files
richter c5b17dbc8d Initial commit: content-free bilingual wiki engine
Self-contained Go application, no database server. Pages, users and
configuration live as JSON/HTML files under wiki_data/, which ships with
placeholder start and imprint pages only — everything institution-specific
is configured at runtime via /settings.

Features: bilingual pages (DE/EN) up to four levels deep with sidebar
navigation and full-text search, a WYSIWYG editor with per-page uploads,
per-page access control (external/internal) and drafts, version history
with restore, local or LDAP login with locally managed roles, bcrypt
password hashing, CSRF protection and login rate limiting, and TLS with
hot certificate reload.

The editor (Quill) and the webfonts are vendored under static/vendor/, so
the application makes no third-party requests at runtime.
2026-07-28 19:40:13 +02:00

309 lines
8.8 KiB
Go

package main
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/flosch/pongo2/v6"
)
var (
// Global servers for dynamic restarts
httpServer *http.Server
httpsServer *http.Server
serverMutex sync.Mutex
mainRouter http.Handler
)
func initPaths() {
cwd, err := os.Getwd()
if err != nil {
log.Fatalf("Error getting working directory: %v", err)
}
dataDir = filepath.Join(cwd, "wiki_data")
pagesDir = filepath.Join(dataDir, "pages")
imagesDir = filepath.Join(dataDir, "images")
filesDir = filepath.Join(dataDir, "files")
_ = os.MkdirAll(pagesDir, 0755)
templateSet = pongo2.NewSet("html", pongo2.MustNewLocalFileSystemLoader("templates"))
}
func decryptPrivateKey(keyPath string, passphrase string) ([]byte, error) {
if passphrase == "" {
return os.ReadFile(keyPath)
}
// We use openssl to decrypt the key file to keep the binary portable without external Go libraries.
cmd := exec.Command("openssl", "pkey", "-in", keyPath, "-passin", "pass:"+passphrase)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("%v (stderr: %s)", err, strings.TrimSpace(stderr.String()))
}
return out.Bytes(), nil
}
// fileStamp identifies a version of a file on disk without reading it.
type fileStamp struct {
modTime time.Time
size int64
}
func stampOf(path string) (fileStamp, error) {
info, err := os.Stat(path)
if err != nil {
return fileStamp{}, err
}
return fileStamp{modTime: info.ModTime(), size: info.Size()}, nil
}
// certReloader keeps the served TLS certificate in sync with the files on disk,
// so a renewed certificate takes effect without restarting the application —
// just drop the new files in place under the same paths. It is wired into
// tls.Config.GetCertificate, which Go calls once per handshake; the files are
// therefore only re-read (and, with a passphrase set, only decrypted through
// the openssl subprocess in decryptPrivateKey) when their modification time or
// size actually changed.
type certReloader struct {
certPath string
keyPath string
passphrase string
mu sync.Mutex
cert *tls.Certificate
certStamp fileStamp
keyStamp fileStamp
}
// newCertReloader loads the key pair once, so that startServers() can tell
// whether HTTPS can be enabled at all.
func newCertReloader(certPath, keyPath, passphrase string) (*certReloader, error) {
cr := &certReloader{certPath: certPath, keyPath: keyPath, passphrase: passphrase}
cr.mu.Lock()
defer cr.mu.Unlock()
certStamp, keyStamp, err := cr.stamps()
if err != nil {
return nil, err
}
if err := cr.reload(certStamp, keyStamp); err != nil {
return nil, err
}
return cr, nil
}
// stamps reads both file stamps. Callers must hold cr.mu.
func (cr *certReloader) stamps() (fileStamp, fileStamp, error) {
certStamp, err := stampOf(cr.certPath)
if err != nil {
return fileStamp{}, fileStamp{}, fmt.Errorf("cannot stat cert file %s: %v", cr.certPath, err)
}
keyStamp, err := stampOf(cr.keyPath)
if err != nil {
return fileStamp{}, fileStamp{}, fmt.Errorf("cannot stat key file %s: %v", cr.keyPath, err)
}
return certStamp, keyStamp, nil
}
// reload re-reads the key pair from disk and replaces the cached certificate.
// Callers must hold cr.mu and must have stat'ed the files *before* reading them,
// so that a file changing mid-reload is picked up again on the next handshake
// rather than being missed. The stamps are recorded even when the reload fails,
// so a half-written or broken file is not retried on every single handshake —
// the next attempt happens once the files change again.
func (cr *certReloader) reload(certStamp, keyStamp fileStamp) error {
cr.certStamp = certStamp
cr.keyStamp = keyStamp
certBytes, err := os.ReadFile(cr.certPath)
if err != nil {
return fmt.Errorf("cannot read cert file %s: %v", cr.certPath, err)
}
keyBytes, err := decryptPrivateKey(cr.keyPath, cr.passphrase)
if err != nil {
return fmt.Errorf("decryption failed for %s: %v", cr.keyPath, err)
}
cert, err := tls.X509KeyPair(certBytes, keyBytes)
if err != nil {
return fmt.Errorf("failed to parse key pair: %v", err)
}
cr.cert = &cert
return nil
}
// GetCertificate is the tls.Config hook. A failed reload is logged and the
// previously loaded certificate keeps being served, so replacing the files with
// something unusable degrades to "old certificate" instead of "no HTTPS".
func (cr *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cr.mu.Lock()
defer cr.mu.Unlock()
certStamp, keyStamp, err := cr.stamps()
if err != nil {
log.Printf("TLS certificate reload skipped, continuing with the previously loaded certificate: %v", err)
} else if certStamp != cr.certStamp || keyStamp != cr.keyStamp {
if err := cr.reload(certStamp, keyStamp); err != nil {
log.Printf("TLS certificate reload failed, continuing with the previously loaded certificate: %v", err)
} else {
log.Printf("TLS certificate reloaded from %s", cr.certPath)
}
}
if cr.cert == nil {
return nil, fmt.Errorf("no TLS certificate available")
}
return cr.cert, nil
}
func startServers() {
serverMutex.Lock()
defer serverMutex.Unlock()
config := loadConfig()
hasTLS := false
var tlsCert tls.Certificate
var certSource *certReloader
if strings.TrimSpace(config.TLSCertPath) != "" && strings.TrimSpace(config.TLSKeyPath) != "" {
reloader, err := newCertReloader(config.TLSCertPath, config.TLSKeyPath, config.TLSKeyPassphrase)
if err != nil {
log.Printf("TLS/HTTPS Load Certificate Error: %v", err)
} else {
certSource = reloader
hasTLS = true
}
} else if strings.TrimSpace(config.TLSCert) != "" && strings.TrimSpace(config.TLSKey) != "" {
cert, err := tls.X509KeyPair([]byte(config.TLSCert), []byte(config.TLSKey))
if err != nil {
log.Printf("TLS/HTTPS Certificate Error (legacy fallback): %v", err)
} else {
tlsCert = cert
hasTLS = true
}
}
httpPort := strings.TrimSpace(config.HTTPPort)
if httpPort == "" {
httpPort = "80"
}
httpsPort := strings.TrimSpace(config.HTTPSPort)
if httpsPort == "" {
httpsPort = "443"
}
if hasTLS {
// Start HTTPS server on port httpsPort in a goroutine. With cert/key
// paths configured the certificate is served through certReloader, so
// swapping the files on disk is picked up automatically; the deprecated
// inline TLSCert/TLSKey fields have nothing to watch and stay static.
tlsConfig := &tls.Config{}
if certSource != nil {
tlsConfig.GetCertificate = certSource.GetCertificate
} else {
tlsConfig.Certificates = []tls.Certificate{tlsCert}
}
httpsServer = &http.Server{
Addr: "0.0.0.0:" + httpsPort,
Handler: mainRouter,
TLSConfig: tlsConfig,
}
go func() {
log.Printf("Starting Go HTTPS web server on https://localhost:%s", httpsPort)
if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
log.Printf("HTTPS Server failed: %v", err)
}
}()
// Start HTTP redirection server on httpPort (redirect to HTTPS)
httpRedirectHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
host := req.Host
if strings.Contains(host, ":") {
hostPart, _, err := net.SplitHostPort(host)
if err == nil {
host = hostPart
}
}
if httpsPort != "443" {
host = host + ":" + httpsPort
}
target := "https://" + host + req.URL.RequestURI()
http.Redirect(w, req, target, http.StatusFound)
})
httpServer = &http.Server{
Addr: "0.0.0.0:" + httpPort,
Handler: httpRedirectHandler,
}
go func() {
log.Printf("Starting Go HTTP redirection server on http://localhost:%s (redirecting to HTTPS)", httpPort)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("HTTP Server failed: %v", err)
}
}()
} else {
httpServer = &http.Server{
Addr: "0.0.0.0:" + httpPort,
Handler: mainRouter,
}
go func() {
log.Printf("Starting Go web server on http://localhost:%s", httpPort)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("HTTP Server failed: %v", err)
}
}()
}
}
func restartServers() {
go func() {
// Wait a moment for response to finish sending to client
time.Sleep(500 * time.Millisecond)
serverMutex.Lock()
log.Println("Restarting web servers to apply new settings...")
// Shutdown HTTP server
if httpServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = httpServer.Shutdown(ctx)
cancel()
httpServer = nil
}
// Shutdown HTTPS server
if httpsServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = httpsServer.Shutdown(ctx)
cancel()
httpsServer = nil
}
serverMutex.Unlock()
// Start servers again
startServers()
}()
}