// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package app assembles the volumen HTTP server: shared services, // route registration, and the middleware chain. package app import ( "crypto/rand" "encoding/hex" json "encoding/json/v2" "errors" "fmt" "log/slog" "net/http" "os" "path/filepath" "slices" "strings" "syscall" "time" "sourcedock.dev/petrbalvin/volumen/internal/admin" "sourcedock.dev/petrbalvin/volumen/internal/audit" "sourcedock.dev/petrbalvin/volumen/internal/backup" "sourcedock.dev/petrbalvin/volumen/internal/config" "sourcedock.dev/petrbalvin/volumen/internal/httpapi" "sourcedock.dev/petrbalvin/volumen/internal/imagefile" "sourcedock.dev/petrbalvin/volumen/internal/payloads" "sourcedock.dev/petrbalvin/volumen/internal/post" "sourcedock.dev/petrbalvin/volumen/internal/ratelimit" "sourcedock.dev/petrbalvin/volumen/internal/session" "sourcedock.dev/petrbalvin/volumen/internal/store" "sourcedock.dev/petrbalvin/volumen/internal/templates" "sourcedock.dev/petrbalvin/volumen/internal/tokens" "sourcedock.dev/petrbalvin/volumen/internal/users" "sourcedock.dev/petrbalvin/volumen/internal/version" "sourcedock.dev/petrbalvin/volumen/internal/web" "sourcedock.dev/petrbalvin/volumen/internal/webhooks" ) // Server holds every long-lived service the handlers share. type Server struct { Config *config.Config Store *store.Store Users *users.Users Templates *templates.Store Tokens *tokens.Store Audit *audit.Log LoginLim *ratelimit.LoginLimiter Sessions *session.Store Webhooks *webhooks.Manager Admin *admin.Admin OnEvent func(event string, payload map[string]any) // previewKey is the session secret New resolved, which the admin // signs preview links with and the API verifies them against. previewKey string } // New validates the configuration and builds the server with all // file-backed stores derived from it. func New(cfg *config.Config, st *store.Store) (*Server, error) { if err := cfg.Validate(); err != nil { return nil, err } secret, err := sessionSecret(cfg) if err != nil { return nil, err } cookieSecure := cfg.Server.CookieSecure || cfg.Server.TrustProxy usersPath := cfg.UsersFile hooks := make([]webhooks.Webhook, 0, len(cfg.Webhooks)) for _, hook := range cfg.Webhooks { hooks = append(hooks, webhooks.Webhook{ URL: hook.URL, Secret: hook.Secret, Events: hook.Events, Enabled: hook.Delivers(), }) } staticHooks := slices.Clone(hooks) // The admin-managed hooks live beside the users file and apply // without a restart. A file that cannot be read is a real fault and // is reported, but it does not take the server down: the configured // hooks still deliver. webhooksFile := filepath.Join(filepath.Dir(usersPath), "webhooks.toml") fileHooks, err := webhooks.LoadFile(webhooksFile) if err != nil { slog.Warn("app: ignoring the webhook store", "path", webhooksFile, "error", err) } else { hooks = append(hooks, fileHooks...) } manager := webhooks.NewManager(hooks, version.Version()) srv := &Server{ Config: cfg, Store: st, Users: users.New(usersPath), Templates: templates.New(cfg.TemplatesFile()), Tokens: tokens.New(cfg.TokensFile()), Audit: audit.New(cfg.AuditLog), LoginLim: ratelimit.NewLoginLimiter(), Sessions: session.New(secret, time.Duration(cfg.Admin.SessionTTL)*time.Second, cookieSecure), Webhooks: manager, previewKey: secret, OnEvent: func(event string, payload map[string]any) { manager.Fire(event, payload, false) }, } adminHandler, err := admin.New(admin.Deps{ Config: cfg, Store: st, Users: srv.Users, Templates: srv.Templates, Tokens: srv.Tokens, Audit: srv.Audit, LoginLim: srv.LoginLim, Sessions: srv.Sessions, Webhooks: manager, PreviewKey: secret, WebhooksFile: webhooksFile, StaticWebhooks: staticHooks, Version: version.Version(), OnEvent: srv.OnEvent, Backup: BackupOptions(cfg), }) if err != nil { return nil, fmt.Errorf("init admin UI: %w", err) } srv.Admin = adminHandler return srv, nil } // Handler builds the full middleware chain and route tree. // // Uploaded media and the backup export are served on their own branches: // the session middleware and the gzip wrapper buffer whole responses, // which would hold entire files in memory. Everything else flows through // the full chain. func (s *Server) Handler() http.Handler { secure := s.Config.Server.CookieSecure || s.Config.Server.TrustProxy mediaMux := http.NewServeMux() mediaMux.HandleFunc("GET /media/{name...}", s.handleMedia) mediaHandler := web.SecurityHeaders(secure)(mediaMux) adminHandler := s.Admin.Handler() // The backup export streams: it keeps the admin authentication but // bypasses the wrappers that hold a whole response in memory, the // session recorder and the gzip wrapper, so the archive reaches the // client as it is written instead of waiting in a second copy. The // session attaches read-only; a GET never mutates it. var exportHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess := s.Sessions.Load(r) adminHandler.ServeHTTP(w, r.WithContext(session.WithContext(r.Context(), sess))) }) exportHandler = web.CrossOrigin()(exportHandler) exportHandler = web.SecurityHeaders(secure)(exportHandler) mux := http.NewServeMux() api := httpapi.New(httpapi.Deps{ Config: s.Config, Store: s.Store, Tokens: s.Tokens, OnEvent: s.OnEvent, PreviewKey: s.previewKey, }) mux.Handle("/api/volumen/", api) mux.Handle("/admin/", adminHandler) mux.Handle("/admin", adminHandler) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Location", "/admin/") w.WriteHeader(http.StatusSeeOther) }) mux.HandleFunc("GET /healthz", s.handleHealthz) mux.HandleFunc("GET /robots.txt", s.handleRobots) mux.HandleFunc("GET /sitemap.xml", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Location", "/api/volumen/sitemap.xml") w.WriteHeader(http.StatusMovedPermanently) }) mux.HandleFunc("GET /favicon.ico", s.handleFavicon) mux.HandleFunc("/", s.handleNotFound) var handler http.Handler = web.RequestLogger(mux) handler = s.Sessions.Middleware(handler) handler = s.apiRateLimit(handler) handler = web.SecurityHeaders(secure)(handler) handler = web.CrossOrigin()(handler) handler = web.Gzip(500)(handler) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/admin/settings/export" { exportHandler.ServeHTTP(w, r) return } if strings.HasPrefix(r.URL.Path, "/media/") { mediaHandler.ServeHTTP(w, r) return } handler.ServeHTTP(w, r) }) } func (s *Server) apiRateLimit(next http.Handler) http.Handler { if s.Config.API.RateLimit <= 0 { return next } limiter := ratelimit.New( s.Config.API.RateLimit, time.Duration(s.Config.API.RateLimitWindow)*time.Second, ) trusted, err := s.Config.TrustedProxyPrefixes() if err != nil || !s.Config.Server.TrustProxy { trusted = nil } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/volumen" && !strings.HasPrefix(r.URL.Path, "/api/volumen/") { next.ServeHTTP(w, r) return } allowed, remaining, retryAfter := limiter.Check(web.ClientIP(r, trusted)) if !allowed { w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter)) w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", limiter.Limit())) w.Header().Set("X-RateLimit-Remaining", "0") for key, value := range map[string]string{ "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", } { w.Header().Set(key, value) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) _ = json.MarshalWrite(w, map[string]any{ "error": "rate_limited", "retry_after": retryAfter, }, json.Deterministic(true)) return } w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", limiter.Limit())) w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining)) next.ServeHTTP(w, r) }) } func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { checks := map[string]string{} overall := "ok" if info, err := os.Stat(s.Config.ContentDir); err == nil && info.IsDir() { checks["content_dir"] = "ok" } else { checks["content_dir"] = "missing" overall = "degraded" } switch err := s.Users.Health(); { case err != nil: // A file that cannot be read means nobody can sign in, which is // not a healthy deployment: say so rather than reporting a count // of zero accounts. slog.Error("volumen: healthz cannot read the users file", "error", err) checks["users_file"] = "unreadable" overall = "degraded" default: if info, statErr := os.Stat(s.Config.UsersFile); statErr == nil && info.Mode().IsRegular() { checks["users_file"] = "ok" } else { checks["users_file"] = "missing (no accounts yet; /admin runs the first-run wizard)" } } if err := s.Tokens.Health(); err != nil { slog.Error("volumen: healthz cannot read the tokens file", "error", err) checks["tokens_file"] = "unreadable" overall = "degraded" } if err := s.Templates.Health(); err != nil { slog.Error("volumen: healthz cannot read the templates file", "error", err) checks["templates_file"] = "unreadable" overall = "degraded" } if skipped := s.Store.Unreadable(); len(skipped) > 0 { checks["content_files"] = fmt.Sprintf("%d file(s) cannot be parsed", len(skipped)) overall = "degraded" } freeMB, err := freeDiskMB(s.Config.ContentDir) switch { case err != nil: // The path and the OS error are logged, not published: this // endpoint is anonymous. slog.Warn("volumen: healthz cannot read the content directory", "error", err) checks["disk"] = "error" case freeMB < 100: checks["disk"] = fmt.Sprintf("low: %.0f MB free", freeMB) overall = "degraded" default: checks["disk"] = fmt.Sprintf("ok (%.0f MB free)", freeMB) } status := http.StatusOK if overall != "ok" { status = http.StatusServiceUnavailable } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.MarshalWrite(w, map[string]any{"status": overall, "checks": checks}, json.Deterministic(true)) } func freeDiskMB(path string) (float64, error) { var st syscall.Statfs_t if err := syscall.Statfs(path, &st); err != nil { return 0, err } return float64(st.Bavail) * float64(st.Bsize) / (1024 * 1024), nil } func (s *Server) handleRobots(w http.ResponseWriter, _ *http.Request) { base := s.Config.Site.BaseURL w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintf(w, "User-agent: *\nAllow: /\nSitemap: %s/api/volumen/sitemap.xml\n", base) } func (s *Server) handleFavicon(w http.ResponseWriter, _ *http.Request) { icon, err := web.StaticFile("volumen-icon.svg") if err != nil { w.WriteHeader(http.StatusNotFound) return } w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "public, max-age=86400") w.WriteHeader(http.StatusOK) _, _ = w.Write(icon) } func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") mediaPath, err := s.Store.MediaPath(name) if err != nil { // A name that is not an allowed image, that would escape the // media directory, or that names nothing, is a 404: the route is // public, so it says nothing about why. s.writeNotFound(w) return } // The type is set from the name's extension rather than sniffed, so a // file whose bytes do not match its extension is still served as an // image and never as a document. contentType := imagefile.ContentType(name) w.Header().Set("Cache-Control", "public, max-age=604800") w.Header().Set("Content-Type", contentType) if contentType == imagefile.MIMESVG { // An SVG is the one accepted image that is also a document: // opened at its own URL it would run on this origin. The // sandbox and the locked-down policy make that a dead // document, while the uses of the file ignore both. w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; sandbox") } http.ServeFile(w, r, mediaPath) } func (s *Server) writeNotFound(w http.ResponseWriter) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) _ = json.MarshalWrite(w, map[string]any{"error": "not_found"}, json.Deterministic(true)) } func (s *Server) handleNotFound(w http.ResponseWriter, _ *http.Request) { s.writeNotFound(w) } // BackupOptions names the files an archive carries, for the admin UI and // the CLI export and import. func BackupOptions(cfg *config.Config) backup.Options { return backup.Options{ ContentDir: cfg.ContentDir, UsersFile: cfg.UsersFile, TemplatesFile: cfg.TemplatesFile(), TokensFile: cfg.TokensFile(), } } // PublishEvent reports that a scheduled post went live, as the same // post.published event the admin delivers, so a hook subscribed to it // hears about a post the scheduler published. func (s *Server) PublishEvent(p *post.Post) { if s.OnEvent == nil || p == nil { return } s.OnEvent("post.published", map[string]any{"post": payloads.BuildSummary(p)}) } // sessionSecret resolves the cookie signing key. The [admin].session_key // in config is an override; with nothing set the server keeps its own // secret in secret.key beside the users file, generating one on first // start so a fresh installation can sign in without the operator // editing the config. Production refuses a key shorter than 64 bytes // whatever its source; development falls back to the ephemeral secret // of session.New when the file cannot be written. func sessionSecret(cfg *config.Config) (string, error) { if key := cfg.Admin.SessionKey; key != "" { return checkedSecret(cfg, key, "[admin].session_key") } path := cfg.SecretKeyFile() raw, err := os.ReadFile(path) switch { case err == nil: if key := strings.TrimSpace(string(raw)); key != "" { return checkedSecret(cfg, key, path) } // An empty file is treated as no file: one more start and the // key is generated and written, so the state converges. case !errors.Is(err, os.ErrNotExist): if cfg.IsProduction() { return "", fmt.Errorf("read %s: %w", path, err) } slog.Warn("app: cannot read the session secret file", "path", path, "error", err) return "", nil } // 32 random bytes as 64 hex characters, which is the length // production requires. A system failure here dies inside // crypto/rand rather than signing sessions with less entropy than // the key looks like. buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("generate the session secret: %w", err) } key := hex.EncodeToString(buf) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if cfg.IsProduction() { return "", fmt.Errorf("create %s: %w", filepath.Dir(path), err) } slog.Warn("app: cannot create the session secret directory; using an ephemeral secret", "error", err) return "", nil } // O_EXCL so two servers racing on a fresh data directory cannot // each write a different key: the loser reads the winner's file. f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if errors.Is(err, os.ErrExist) { raw, err := os.ReadFile(path) if err != nil { if cfg.IsProduction() { return "", fmt.Errorf("read %s: %w", path, err) } return "", nil } return checkedSecret(cfg, strings.TrimSpace(string(raw)), path) } if err != nil { if cfg.IsProduction() { return "", fmt.Errorf("write %s: %w (or set [admin].session_key)", path, err) } slog.Warn("app: cannot write the session secret file; using an ephemeral secret", "error", err) return "", nil } if _, err := f.WriteString(key + "\n"); err != nil { f.Close() if cfg.IsProduction() { return "", fmt.Errorf("write %s: %w", path, err) } return "", nil } if err := f.Close(); err != nil && cfg.IsProduction() { return "", fmt.Errorf("write %s: %w", path, err) } slog.Info("app: generated the session secret", "path", path) return key, nil } // checkedSecret applies the production length rule to a key named by // its source, which is the config field or the secret file. func checkedSecret(cfg *config.Config, key, source string) (string, error) { if len(key) < 64 && cfg.IsProduction() { return "", fmt.Errorf( "%s must be at least 64 bytes in production (got %d). "+ "Generate one with: openssl rand -hex 32", source, len(key)) } return key, nil }