Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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 <img> 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
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/preview"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, http.Handler) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
content := filepath.Join(dir, "posts")
|
||||
if err := os.MkdirAll(content, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
|
||||
Port: -1,
|
||||
ContentDir: content,
|
||||
UsersFile: filepath.Join(dir, "users.toml"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
cfg.Site.BaseURL = "https://site.example"
|
||||
cfg.Server.Env = config.EnvProduction
|
||||
cfg.Server.CookieSecure = true
|
||||
cfg.Admin.SessionKey = strings.Repeat("s", 64)
|
||||
|
||||
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
|
||||
srv, err := New(cfg, st)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return srv, srv.Handler()
|
||||
}
|
||||
|
||||
// With no session_key in the config, production starts anyway: the
|
||||
// server generates its secret into secret.key beside the users file,
|
||||
// owner-only, and every later start reuses the same key so sessions
|
||||
// survive a restart. An explicitly configured key still wins and is
|
||||
// still length-checked.
|
||||
func TestNewGeneratesSessionSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
|
||||
Port: -1,
|
||||
ContentDir: filepath.Join(dir, "posts"),
|
||||
UsersFile: filepath.Join(dir, "users.toml"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
cfg.Server.Env = "production"
|
||||
if err := os.MkdirAll(filepath.Join(dir, "posts"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if _, err := New(cfg, store.New(store.Options{ContentDir: filepath.Join(dir, "posts"), DefaultLang: "en", RevisionLimit: 10})); err != nil {
|
||||
t.Fatalf("production with no session_key must generate the secret: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "secret.key"))
|
||||
if err != nil {
|
||||
t.Fatalf("read secret.key: %v", err)
|
||||
}
|
||||
key := strings.TrimSpace(string(raw))
|
||||
if len(key) < 64 {
|
||||
t.Fatalf("generated key length = %d", len(key))
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dir, "secret.key"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat secret.key: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("secret.key mode = %v, want 0600", info.Mode().Perm())
|
||||
}
|
||||
|
||||
// The second start reads the file back and keeps the same key.
|
||||
cfg2, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
|
||||
Port: -1,
|
||||
ContentDir: filepath.Join(dir, "posts"),
|
||||
UsersFile: filepath.Join(dir, "users.toml"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
cfg2.Server.Env = "production"
|
||||
secret, err := sessionSecret(cfg2)
|
||||
if err != nil {
|
||||
t.Fatalf("sessionSecret on the second start: %v", err)
|
||||
}
|
||||
if secret != key {
|
||||
t.Fatal("the generated secret changed between starts")
|
||||
}
|
||||
|
||||
// A configured override wins, and production still rejects it short.
|
||||
cfg2.Admin.SessionKey = "short"
|
||||
if _, err := sessionSecret(cfg2); err == nil {
|
||||
t.Fatal("want error for a short production override")
|
||||
}
|
||||
}
|
||||
|
||||
// The generated secret.key signs preview links too, the way the
|
||||
// configuration documents admin.session_key: a default deployment, with
|
||||
// no key in the config, honours a preview token minted from the file,
|
||||
// through the wired handler the browser talks to.
|
||||
func TestGeneratedSecretSignsPreviewLinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := filepath.Join(dir, "posts")
|
||||
if err := os.MkdirAll(content, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
draft := "+++\ntitle = \"Draft\"\nslug = \"draft\"\ndate = 2026-08-18\ndraft = true\n+++\n\nBody.\n"
|
||||
if err := os.WriteFile(filepath.Join(content, "draft.md"), []byte(draft), 0o644); err != nil {
|
||||
t.Fatalf("write draft: %v", err)
|
||||
}
|
||||
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
|
||||
Port: -1,
|
||||
ContentDir: content,
|
||||
UsersFile: filepath.Join(dir, "users.toml"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
cfg.Server.Env = "production"
|
||||
cfg.Site.BaseURL = "https://site.example"
|
||||
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
|
||||
srv, err := New(cfg, st)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "secret.key"))
|
||||
if err != nil {
|
||||
t.Fatalf("read secret.key: %v", err)
|
||||
}
|
||||
key := strings.TrimSpace(string(raw))
|
||||
if key == "" {
|
||||
t.Fatal("no secret.key was generated")
|
||||
}
|
||||
|
||||
handler := srv.Handler()
|
||||
token := preview.Token("draft", key, time.Now())
|
||||
if token == "" {
|
||||
t.Fatal("the generated key cannot sign a preview token")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
|
||||
"/api/volumen/posts/draft?preview_token="+token, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("preview through the wired handler = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Draft") {
|
||||
t.Fatalf("the draft body did not reach the response: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Development starts without a writable location for the file too:
|
||||
// the ephemeral fallback keeps the server usable, with the warning as
|
||||
// the only signal.
|
||||
func TestSessionSecretOverrideInDevelopment(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
|
||||
Port: -1,
|
||||
ContentDir: filepath.Join(dir, "posts"),
|
||||
UsersFile: filepath.Join(dir, "users.toml"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
cfg.Admin.SessionKey = "a-development-key"
|
||||
secret, err := sessionSecret(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("development accepts a short override: %v", err)
|
||||
}
|
||||
if secret != "a-development-key" {
|
||||
t.Fatalf("secret = %q", secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootRedirectsToAdmin(t *testing.T) {
|
||||
_, handler := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
srv, handler := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decode(t, rec)
|
||||
if body["status"] != "ok" {
|
||||
t.Fatalf("body = %v", body)
|
||||
}
|
||||
checks := body["checks"].(map[string]any)
|
||||
if checks["content_dir"] != "ok" {
|
||||
t.Fatalf("checks = %v", checks)
|
||||
}
|
||||
if checks["users_file"] == nil || checks["disk"] == nil {
|
||||
t.Fatalf("checks = %v", checks)
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("healthz must not be cached")
|
||||
}
|
||||
|
||||
// Missing content directory degrades the status.
|
||||
if err := os.RemoveAll(srv.Config.ContentDir); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("invalid JSON %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRobotsSitemapFavicon(t *testing.T) {
|
||||
_, handler := newTestServer(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
|
||||
if !strings.Contains(rec.Body.String(), "Sitemap: https://site.example/api/volumen/sitemap.xml") {
|
||||
t.Fatalf("robots = %q", rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil))
|
||||
if rec.Code != http.StatusMovedPermanently ||
|
||||
rec.Header().Get("Location") != "/api/volumen/sitemap.xml" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/favicon.ico", nil))
|
||||
if rec.Code != http.StatusOK ||
|
||||
rec.Header().Get("Content-Type") != "image/svg+xml" {
|
||||
t.Fatalf("code=%d type=%q", rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "<svg") {
|
||||
t.Fatal("favicon body is not SVG")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaServing(t *testing.T) {
|
||||
srv, handler := newTestServer(t)
|
||||
mediaDir := filepath.Join(srv.Store.ContentDir, store.MediaDirName)
|
||||
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mediaDir, "pic.webp"), []byte("IIIIIIIIWEBP"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>`)
|
||||
if err := os.WriteFile(filepath.Join(mediaDir, "figure.svg"), svg, 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/pic.webp", nil))
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "IIIIIIIIWEBP" {
|
||||
t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "public, max-age=604800" {
|
||||
t.Fatalf("cache-control = %q", rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
// A raster image gets no document policy of its own.
|
||||
if strings.Contains(rec.Header().Get("Content-Security-Policy"), "sandbox") {
|
||||
t.Fatal("webp response carries a sandbox policy")
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/figure.svg", nil))
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != string(svg) {
|
||||
t.Fatalf("svg code=%d body=%q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("Content-Type") != "image/svg+xml" {
|
||||
t.Fatalf("svg content-type = %q", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
// An SVG opened at its own URL is a document on this origin: the
|
||||
// response must sandbox it.
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
if !strings.Contains(csp, "sandbox") || !strings.Contains(csp, "default-src 'none'") {
|
||||
t.Fatalf("svg content-security-policy = %q, want a sandboxed document", csp)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/missing.webp", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFoundJSON(t *testing.T) {
|
||||
_, handler := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/nope", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if decode(t, rec)["error"] != "not_found" {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersOnAPI(t *testing.T) {
|
||||
_, handler := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
|
||||
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatal("security headers missing")
|
||||
}
|
||||
if strings.Contains(rec.Header().Get("Content-Security-Policy"), "nonce-") {
|
||||
t.Fatal("nonce leaked onto API response")
|
||||
}
|
||||
if rec.Header().Get("Strict-Transport-Security") == "" {
|
||||
t.Fatal("HSTS missing on secure deployment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRateLimitHeaders(t *testing.T) {
|
||||
srv, handler := newTestServer(t)
|
||||
srv.Config.API.RateLimit = 2
|
||||
srv.Config.API.RateLimitWindow = 60
|
||||
handler = srv.Handler() // rebuild with the new limit
|
||||
|
||||
var limited bool
|
||||
for range 5 {
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
limited = true
|
||||
body := decode(t, rec)
|
||||
if body["error"] != "rate_limited" {
|
||||
t.Fatalf("body = %v", body)
|
||||
}
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Fatal("Retry-After missing")
|
||||
}
|
||||
break
|
||||
}
|
||||
if rec.Header().Get("X-RateLimit-Limit") != "2" {
|
||||
t.Fatalf("limit header = %q", rec.Header().Get("X-RateLimit-Limit"))
|
||||
}
|
||||
}
|
||||
if !limited {
|
||||
t.Fatal("rate limit never triggered")
|
||||
}
|
||||
|
||||
// Non-API routes are not limited.
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("robots blocked: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipThroughChain(t *testing.T) {
|
||||
srv, handler := newTestServer(t)
|
||||
// A large post body pushes the JSON response over the gzip threshold.
|
||||
var b strings.Builder
|
||||
b.WriteString("+++\ntitle = \"Big\"\nslug = \"big\"\ndate = 2026-01-01\nexcerpt = \"")
|
||||
b.WriteString(strings.Repeat("x", 600))
|
||||
b.WriteString("\"\n+++\nbody\n")
|
||||
if err := os.WriteFile(filepath.Join(srv.Config.ContentDir, "big.md"), []byte(b.String()), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/big", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Fatalf("content-encoding = %q (body %d bytes)",
|
||||
rec.Header().Get("Content-Encoding"), rec.Body.Len())
|
||||
}
|
||||
if strconv.Itoa(rec.Body.Len()) == "0" {
|
||||
t.Fatal("empty body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookieSecureFlag(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
|
||||
Port: -1,
|
||||
ContentDir: filepath.Join(dir, "posts"),
|
||||
UsersFile: filepath.Join(dir, "users.toml"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "posts"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
// trust_proxy implies secure cookies even without cookie_secure.
|
||||
cfg.Server.TrustProxy = true
|
||||
srv, err := New(cfg, store.New(store.Options{ContentDir: filepath.Join(dir, "posts"), DefaultLang: "en", RevisionLimit: 10}))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if !srv.Sessions.Secure() {
|
||||
t.Fatal("trust_proxy must imply secure session cookies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaServedWithoutGzipBuffering(t *testing.T) {
|
||||
srv, handler := newTestServer(t)
|
||||
mediaDir := filepath.Join(srv.Store.ContentDir, store.MediaDirName)
|
||||
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
// Repetitive content that gzip would shrink dramatically if applied.
|
||||
payload := strings.Repeat("WEBPDATA", 2000)
|
||||
if err := os.WriteFile(filepath.Join(mediaDir, "big.webp"), []byte(payload), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/media/big.webp", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if enc := rec.Header().Get("Content-Encoding"); enc != "" {
|
||||
t.Fatalf("media was compressed (%q); it must stream untouched", enc)
|
||||
}
|
||||
if rec.Body.Len() != len(payload) {
|
||||
t.Fatalf("body length = %d, want %d", rec.Body.Len(), len(payload))
|
||||
}
|
||||
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatal("security headers missing on media responses")
|
||||
}
|
||||
}
|
||||
|
||||
// The backup export streams on its own branch: the archive arrives
|
||||
// compressed by the backup writer alone, never re-wrapped by the gzip
|
||||
// middleware, which also proves the response never waited in that
|
||||
// wrapper's buffer.
|
||||
func TestExportStreamsUnwrapped(t *testing.T) {
|
||||
srv, handler := newTestServer(t)
|
||||
if _, err := srv.Users.Add("admin", "correct-horse-battery", "admin"); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
// Enough varied content that the archive crosses the gzip threshold
|
||||
// and would engage the middleware were the export still flowing
|
||||
// through it; repeated bytes compress away and prove nothing.
|
||||
var b strings.Builder
|
||||
b.WriteString("+++\ntitle = \"Big\"\nslug = \"big\"\ndate = 2026-01-01\nexcerpt = \"")
|
||||
for i := range 400 {
|
||||
b.WriteString(strconv.Itoa(i*7919+i*i) + " ")
|
||||
}
|
||||
b.WriteString("\"\n+++\nbody\n")
|
||||
if err := os.WriteFile(filepath.Join(srv.Config.ContentDir, "big.md"), []byte(b.String()), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
login := func() *http.Cookie {
|
||||
t.Helper()
|
||||
form := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
|
||||
handler.ServeHTTP(form, req)
|
||||
csrf := extractCSRF(t, form.Body.String())
|
||||
cookies := form.Result().Cookies()
|
||||
body := strings.NewReader("_csrf=" + url.QueryEscape(csrf) + "&username=admin&password=correct-horse-battery")
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/login", body)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "volumen_session" {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatal("no session cookie after login")
|
||||
return nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
req.AddCookie(login())
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "application/gzip" {
|
||||
t.Fatalf("content-type = %q", got)
|
||||
}
|
||||
if enc := rec.Header().Get("Content-Encoding"); enc != "" {
|
||||
t.Fatalf("export re-compressed (%q); it must stream untouched", enc)
|
||||
}
|
||||
if rec.Body.Len() < 500 {
|
||||
t.Fatalf("body %d bytes, too small to prove the bypass", rec.Body.Len())
|
||||
}
|
||||
if rec.Body.Bytes()[0] != 0x1f || rec.Body.Bytes()[1] != 0x8b {
|
||||
t.Fatal("body does not start with the gzip magic bytes")
|
||||
}
|
||||
}
|
||||
|
||||
// Logout must clear the cookie through the handler built by app.New: the
|
||||
// session services are wired there, and a hand-built Deps in a test would
|
||||
// hide a missing one.
|
||||
func TestLogoutThroughTheWiredHandler(t *testing.T) {
|
||||
srv, handler := newTestServer(t)
|
||||
if _, err := srv.Users.Add("admin", "correct-horse-battery", "admin"); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
login := func() *http.Cookie {
|
||||
t.Helper()
|
||||
form := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
|
||||
handler.ServeHTTP(form, req)
|
||||
csrf := extractCSRF(t, form.Body.String())
|
||||
cookies := form.Result().Cookies()
|
||||
body := strings.NewReader("_csrf=" + url.QueryEscape(csrf) + "&username=admin&password=correct-horse-battery")
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/login", body)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "volumen_session" {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatal("no session cookie after login")
|
||||
return nil
|
||||
}
|
||||
|
||||
session := login()
|
||||
|
||||
// The dashboard is reachable while signed in.
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
req.AddCookie(session)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("dashboard code = %d", rec.Code)
|
||||
}
|
||||
csrf := extractCSRF(t, rec.Body.String())
|
||||
|
||||
// Logging out answers, clears the cookie and refuses the next request.
|
||||
rec = httptest.NewRecorder()
|
||||
body := strings.NewReader("_csrf=" + url.QueryEscape(csrf))
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/logout", body)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(session)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("logout code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
expired := false
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "volumen_session" && c.MaxAge < 0 {
|
||||
expired = true
|
||||
}
|
||||
}
|
||||
if !expired {
|
||||
t.Fatal("logout did not expire the session cookie")
|
||||
}
|
||||
// A copy of the cookie taken before the logout stays valid until it
|
||||
// expires, because the session lives entirely in the cookie. That is
|
||||
// the documented trade-off of a signed cookie without server state,
|
||||
// and why logging out expires the browser's copy rather than claiming
|
||||
// to revoke it.
|
||||
}
|
||||
|
||||
func extractCSRF(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
const marker = `name="_csrf" value="`
|
||||
_, rest, ok := strings.Cut(body, marker)
|
||||
if !ok {
|
||||
t.Fatal("no CSRF token in the page")
|
||||
}
|
||||
token, _, ok := strings.Cut(rest, `"`)
|
||||
if !ok {
|
||||
t.Fatal("malformed CSRF token")
|
||||
}
|
||||
return token
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||||
)
|
||||
|
||||
// BenchmarkServer drives the wired handler over real HTTP: a list, a
|
||||
// single post, a tag feed and the sitemap, against a corpus the size of a
|
||||
// site that has been running for a few years. It is also the workload the
|
||||
// PGO profile is recorded from, which is why it exercises the whole chain
|
||||
// rather than one function.
|
||||
func BenchmarkServer(b *testing.B) {
|
||||
const posts = 500
|
||||
dir := b.TempDir()
|
||||
content := filepath.Join(dir, "posts")
|
||||
if err := os.MkdirAll(content, 0o755); err != nil {
|
||||
b.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
for i := range posts {
|
||||
body := fmt.Sprintf(`+++
|
||||
title = "Post %d"
|
||||
slug = "post-%d"
|
||||
date = 2026-01-%02d
|
||||
lang = "en"
|
||||
tags = ["go", "bench"]
|
||||
series = "Bench"
|
||||
series_order = %d
|
||||
+++
|
||||
|
||||
## Section
|
||||
|
||||
A paragraph with **markup**, a [link](https://example.com) and enough
|
||||
words to make the renderer do real work: %s
|
||||
|
||||
- one
|
||||
- two
|
||||
- three
|
||||
`, i, i, i%28+1, i, strings.Repeat("lorem ipsum dolor sit amet ", 40))
|
||||
path := filepath.Join(content, fmt.Sprintf("post-%d.md", i))
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
b.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
cfg := config.Defaults()
|
||||
cfg.ContentDir = content
|
||||
cfg.UsersFile = filepath.Join(dir, "users.toml")
|
||||
cfg.Admin.SessionKey = strings.Repeat("k", 64)
|
||||
cfg.Site.BaseURL = "https://site.example"
|
||||
cfg.API.RateLimit = 0
|
||||
srv, err := New(cfg, store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10}))
|
||||
if err != nil {
|
||||
b.Fatalf("New: %v", err)
|
||||
}
|
||||
// The access line each request writes is noise inside a benchmark.
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
b.Cleanup(func() { slog.SetDefault(previous) })
|
||||
|
||||
server := httptest.NewServer(srv.Handler())
|
||||
defer server.Close()
|
||||
|
||||
targets := []string{
|
||||
"/api/volumen/posts?limit=20",
|
||||
"/api/volumen/posts/post-250",
|
||||
"/api/volumen/tags",
|
||||
"/api/volumen/tags/go/feed.json",
|
||||
"/api/volumen/sitemap.xml",
|
||||
}
|
||||
client := server.Client()
|
||||
b.ResetTimer()
|
||||
for i := 0; b.Loop(); i++ {
|
||||
resp, err := client.Get(server.URL + targets[i%len(targets)])
|
||||
if err != nil {
|
||||
b.Fatalf("get: %v", err)
|
||||
}
|
||||
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
|
||||
b.Fatalf("read: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b.Fatalf("%s: status %d", targets[i%len(targets)], resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user