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

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+523
View File
@@ -0,0 +1,523 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package config reads config.toml into a typed value, applies the
// command-line overrides, and validates it. The commented template it
// ships, config.toml.example, is the file an operator copies.
//
// There is no configuration map: every key has a field, the decoder
// rejects a key whose TOML type does not match its field, and a key the
// decoder does not know is ignored, so a file written for a newer release
// still loads.
package config
import (
"errors"
"fmt"
"log/slog"
"net/netip"
"net/url"
"os"
"path/filepath"
"strings"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/fediverse"
"sourcedock.dev/petrbalvin/volumen/internal/password"
)
// DefaultPath is the system-wide configuration file location.
const DefaultPath = "/etc/volumen/config.toml"
// UserConfigPath returns the per-user configuration file location,
// resolved from XDG_CONFIG_HOME or ~/.config. It is "" when neither can
// be named.
func UserConfigPath() string {
if d := os.Getenv("XDG_CONFIG_HOME"); d != "" {
return filepath.Join(d, "volumen", "config.toml")
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "volumen", "config.toml")
}
// ResolveConfigPath picks the configuration file `serve` reads when
// --config was not given: the system path if it exists, otherwise the
// per-user path if it exists, otherwise the system path, which does not
// exist and so loads the built-in defaults. A plain `volumen serve` with
// no file anywhere therefore runs on per-user state paths.
func ResolveConfigPath() string {
if _, err := os.Stat(DefaultPath); err == nil {
return DefaultPath
}
if p := UserConfigPath(); p != "" {
if _, err := os.Stat(p); err == nil {
return p
}
}
return DefaultPath
}
// UserStatePaths returns the per-user content and users file paths used
// as the defaults when no configuration file exists, so the server can
// run and write its state under the user's home without root. They are
// resolved from XDG_DATA_HOME or ~/.local/share; ok is false when the
// platform cannot name one.
func UserStatePaths() (contentDir, usersFile string, ok bool) {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", "", false
}
base = filepath.Join(home, ".local", "share")
}
dir := filepath.Join(base, "volumen")
return filepath.Join(dir, "posts"), filepath.Join(dir, "users.toml"), true
}
// The built-in defaults, applied to every key the file leaves out.
const (
DefaultHost = "::"
DefaultPort = 9091
DefaultContentDir = "/var/lib/volumen/posts"
DefaultUsersFile = "/var/lib/volumen/users.toml"
DefaultSiteTitle = "Volumen"
DefaultSiteDescription = "Powered by Volumen."
DefaultBaseURL = "https://example.com"
DefaultLanguage = "en"
DefaultAuthor = "Anonymous"
DefaultSessionTTL = 86400
DefaultMinPasswordLength = 10
DefaultMaxPasswordLength = 1024
DefaultMaxUploadBytes = 10 * 1024 * 1024
DefaultAPIRateLimit = 60
DefaultAPIRateLimitWindow = 60
DefaultRevisionLimit = 10
DefaultSchedulerInterval = 300
// MaxSessionTTL bounds [admin].session_ttl so that it cannot overflow
// a time.Duration when converted to seconds, and so that a session
// cannot outlive a year.
MaxSessionTTL = 365 * 24 * 60 * 60
// MaxRateLimitWindow bounds [api].rate_limit_window for the same
// reason.
MaxRateLimitWindow = 24 * 60 * 60
// MaxUploadBytesCeiling bounds [admin].max_upload_bytes, so that a
// mistyped value cannot be read into memory in one piece.
MaxUploadBytesCeiling = 1 << 30
// PortUnset is the Overrides.Port sentinel meaning "do not override".
PortUnset = -1
EnvProduction = "production"
EnvDevelopment = "development"
LogFormatText = "text"
LogFormatJSON = "json"
)
// ConfigError reports a configuration value the program refuses to run
// with. Validate returns it, and the caller prints it and exits.
type ConfigError struct {
msg string
}
func (e *ConfigError) Error() string { return e.msg }
func errorf(format string, args ...any) *ConfigError {
return &ConfigError{msg: fmt.Sprintf(format, args...)}
}
// Server is the [server] table.
type Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
Env string `toml:"env"`
TrustProxy bool `toml:"trust_proxy"`
// TrustedProxies lists the addresses whose X-Forwarded-For may be
// believed, as addresses or CIDR prefixes. An empty list means the
// header is never read and the connection address is always used;
// list the proxy so its clients each rate-limit under their own
// address.
TrustedProxies []string `toml:"trusted_proxies"`
CookieSecure bool `toml:"cookie_secure"`
LogFormat string `toml:"log_format"`
}
// Site is the [site] table.
type Site struct {
Title string `toml:"title"`
Description string `toml:"description"`
BaseURL string `toml:"base_url"`
Language string `toml:"language"`
Author string `toml:"author"`
FediverseCreator string `toml:"fediverse_creator"`
}
// Admin is the [admin] table.
type Admin struct {
SessionKey string `toml:"session_key"`
SessionTTL int `toml:"session_ttl"`
MinPasswordLength int `toml:"min_password_length"`
MaxPasswordLength int `toml:"max_password_length"`
MaxUploadBytes int `toml:"max_upload_bytes"`
}
// API is the [api] table.
type API struct {
RateLimit int `toml:"rate_limit"`
RateLimitWindow int `toml:"rate_limit_window"`
}
// Scheduler is the [scheduler] table.
type Scheduler struct {
Enabled bool `toml:"enabled"`
Interval int `toml:"interval"`
}
// Webhook is one [[webhooks]] entry.
type Webhook struct {
URL string `toml:"url"`
Secret string `toml:"secret"`
Events []string `toml:"events"`
// Enabled is a pointer so that an omitted key means enabled: a hook
// written without the key is one the operator wants delivered, and
// only an explicit false turns it off.
Enabled *bool `toml:"enabled"`
}
// Delivers reports whether the hook is on.
func (w Webhook) Delivers() bool { return w.Enabled == nil || *w.Enabled }
// Config is the merged configuration: the built-in defaults with the file
// decoded over them and the command-line overrides applied.
type Config struct {
Server Server `toml:"server"`
Site Site `toml:"site"`
Admin Admin `toml:"admin"`
API API `toml:"api"`
Scheduler Scheduler `toml:"scheduler"`
ContentDir string `toml:"content_dir"`
UsersFile string `toml:"users_file"`
RevisionLimit int `toml:"revision_limit"`
AuditLog string `toml:"audit_log"`
Webhooks []Webhook `toml:"webhooks"`
}
// Overrides carries the command-line overrides of the `serve` subcommand.
// An empty string means unset; Port uses PortUnset rather than zero,
// because port 0 is a value a caller could mean to set.
type Overrides struct {
Host string
Port int
ContentDir string
UsersFile string
}
// Defaults returns the built-in configuration.
func Defaults() *Config {
return &Config{
Server: Server{
Host: DefaultHost,
Port: DefaultPort,
Env: EnvDevelopment,
LogFormat: LogFormatText,
},
Site: Site{
Title: DefaultSiteTitle,
Description: DefaultSiteDescription,
BaseURL: DefaultBaseURL,
Language: DefaultLanguage,
Author: DefaultAuthor,
},
Admin: Admin{
SessionTTL: DefaultSessionTTL,
MinPasswordLength: DefaultMinPasswordLength,
MaxPasswordLength: DefaultMaxPasswordLength,
MaxUploadBytes: DefaultMaxUploadBytes,
},
API: API{
RateLimit: DefaultAPIRateLimit,
RateLimitWindow: DefaultAPIRateLimitWindow,
},
Scheduler: Scheduler{
Interval: DefaultSchedulerInterval,
},
ContentDir: DefaultContentDir,
UsersFile: DefaultUsersFile,
RevisionLimit: DefaultRevisionLimit,
}
}
// Load reads path, decodes it over the built-in defaults, applies the
// overrides, and returns the configuration. A missing file is not an
// error: the defaults are used and one line says so. With no file, the
// data paths move under the user's home so a server started without any
// configuration can still write its state and run the first-run wizard;
// an explicit --content or --users-file override wins over that.
func Load(path string, ov Overrides) (*Config, error) {
cfg := Defaults()
raw, err := os.ReadFile(path)
switch {
case err == nil:
if err := decode(raw, cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
case errors.Is(err, os.ErrNotExist):
if content, users, ok := UserStatePaths(); ok {
cfg.ContentDir = content
cfg.UsersFile = users
slog.Info("volumen: configuration file not found, using built-in defaults",
"path", path, "example", "config.toml.example", "content_dir", content)
} else {
slog.Info("volumen: configuration file not found, using built-in defaults",
"path", path, "example", "config.toml.example")
}
default:
return nil, fmt.Errorf("read config %s: %w", path, err)
}
cfg.apply(ov)
return cfg, nil
}
// decode fills cfg from a TOML document, and reports a root key that a
// table header swallowed: TOML puts a key written below [site] inside
// that table, where nothing reads it.
func decode(raw []byte, cfg *Config) error {
tree, err := interpres.ParseMap(raw)
if err != nil {
return err
}
for name, value := range tree {
// Only tables can swallow a root key; [[webhooks]] is an array
// of tables and parses as a slice, so the type check skips it.
sub, ok := value.(map[string]any)
if !ok {
continue
}
for _, key := range foldedKeys {
if _, present := sub[key]; present {
return fmt.Errorf(
"%s is written below the [%s] header, so it belongs to that table; move it above the first [table] header",
key, name)
}
}
}
return interpres.Unmarshal(raw, cfg)
}
// foldedKeys are the keys that sit at the root of the document and are
// silently captured by a preceding table header if they are written below
// one.
var foldedKeys = []string{"content_dir", "users_file", "revision_limit", "audit_log"}
func (c *Config) apply(ov Overrides) {
if ov.Host != "" {
c.Server.Host = ov.Host
}
if ov.Port != PortUnset {
c.Server.Port = ov.Port
}
if ov.ContentDir != "" {
c.ContentDir = ov.ContentDir
}
if ov.UsersFile != "" {
c.UsersFile = ov.UsersFile
}
}
// TemplatesFile returns the templates.toml path, next to users_file.
func (c *Config) TemplatesFile() string {
return filepath.Join(filepath.Dir(c.UsersFile), "templates.toml")
}
// TokensFile returns the tokens.toml path, next to users_file.
func (c *Config) TokensFile() string {
return filepath.Join(filepath.Dir(c.UsersFile), "tokens.toml")
}
// IsProduction reports whether the environment label is production.
func (c *Config) IsProduction() bool { return c.Server.Env == EnvProduction }
// ListenAddr returns the address the server binds, as host:port.
func (c *Config) ListenAddr() (netip.AddrPort, error) {
addr, err := netip.ParseAddr(c.Server.Host)
if err != nil {
return netip.AddrPort{}, errorf("[server].host must be an IP address (got %q)", c.Server.Host)
}
return netip.AddrPortFrom(addr, uint16(c.Server.Port)), nil
}
// TrustedProxyPrefixes parses [server].trusted_proxies. An entry may be a
// single address, which is read as a /32 or /128 prefix.
func (c *Config) TrustedProxyPrefixes() ([]netip.Prefix, error) {
out := make([]netip.Prefix, 0, len(c.Server.TrustedProxies))
for _, entry := range c.Server.TrustedProxies {
if prefix, err := netip.ParsePrefix(entry); err == nil {
out = append(out, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(entry)
if err != nil {
return nil, errorf("[server].trusted_proxies entry %q is not an address or a CIDR prefix", entry)
}
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
}
return out, nil
}
// Validate checks the configuration and returns a *ConfigError naming the
// first problem. A key the decoder could not read is already an error by
// then, so this covers the values a wrong type cannot catch.
func (c *Config) Validate() error {
if c.Server.Host == "" {
return errorf("[server].host must be a non-empty string")
}
if _, err := netip.ParseAddr(c.Server.Host); err != nil {
return errorf("[server].host must be an IP address (got %q)", c.Server.Host)
}
if c.Server.Port < 1 || c.Server.Port > 65535 {
return errorf("[server].port must be an integer in 1..65535 (got %d)", c.Server.Port)
}
if c.Server.Env != EnvDevelopment && c.Server.Env != EnvProduction {
return errorf("[server].env must be one of [development production] (got %q)", c.Server.Env)
}
if c.Server.LogFormat != LogFormatText && c.Server.LogFormat != LogFormatJSON {
return errorf("[server].log_format must be one of [text json] (got %q)", c.Server.LogFormat)
}
if _, err := c.TrustedProxyPrefixes(); err != nil {
return err
}
if c.Server.TrustProxy && !c.IsProduction() {
slog.Warn("config: [server].trust_proxy is on outside production; " +
"only a proxy you control must be able to reach the listener")
}
if c.RevisionLimit < 0 {
return errorf("revision_limit must be >= 0 (got %d)", c.RevisionLimit)
}
if c.Admin.SessionTTL <= 0 {
return errorf("[admin].session_ttl must be > 0 (got %d)", c.Admin.SessionTTL)
}
if c.Admin.SessionTTL > MaxSessionTTL {
return errorf("[admin].session_ttl must be <= %d seconds (got %d)", MaxSessionTTL, c.Admin.SessionTTL)
}
if c.Admin.MinPasswordLength < 1 || c.Admin.MinPasswordLength > c.Admin.MaxPasswordLength {
return errorf("[admin].min_password_length must be >= 1 and <= max_password_length")
}
// The hashing layer refuses anything longer whatever this value
// says; catching it here turns a password-change 500 into a startup
// error the operator can read.
if c.Admin.MaxPasswordLength > password.MaxPasswordLength {
return errorf("[admin].max_password_length must be <= %d (got %d)",
password.MaxPasswordLength, c.Admin.MaxPasswordLength)
}
if c.Admin.MaxUploadBytes <= 0 {
return errorf("[admin].max_upload_bytes must be > 0")
}
if c.Admin.MaxUploadBytes > MaxUploadBytesCeiling {
return errorf("[admin].max_upload_bytes must be <= %d (got %d)", MaxUploadBytesCeiling, c.Admin.MaxUploadBytes)
}
if c.API.RateLimit < 0 {
return errorf("[api].rate_limit must be >= 0 (got %d)", c.API.RateLimit)
}
if c.API.RateLimit > 0 {
if c.API.RateLimitWindow <= 0 {
return errorf("[api].rate_limit_window must be > 0 when rate limiting is enabled")
}
if c.API.RateLimitWindow > MaxRateLimitWindow {
return errorf("[api].rate_limit_window must be <= %d seconds (got %d)", MaxRateLimitWindow, c.API.RateLimitWindow)
}
}
if c.Scheduler.Enabled && c.Scheduler.Interval < 1 {
return errorf("[scheduler].interval must be >= 1 second (got %d)", c.Scheduler.Interval)
}
for _, hook := range c.Webhooks {
parsed, err := url.Parse(hook.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return errorf("[[webhooks]].url must be an http(s) URL (got %q)", hook.URL)
}
}
if c.Site.BaseURL == "" {
return errorf("[site].base_url must be a non-empty string")
}
parsed, err := url.Parse(c.Site.BaseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return errorf("[site].base_url must be an absolute URL (got %q)", c.Site.BaseURL)
}
if c.Site.FediverseCreator != "" && !fediverse.Valid(c.Site.FediverseCreator) {
return errorf("[site].fediverse_creator must look like @user@host when set")
}
if err := probeWritable(c.ContentDir, "[content_dir]"); err != nil {
return err
}
if err := probeWritable(c.UsersFile, "[users_file]"); err != nil {
return err
}
return nil
}
// probeWritable reports whether the directory holding target can be
// written to. It never creates anything: a read-only command validates a
// configuration, and turning a mistyped path into a directory would make
// the mistake harder to see. The directory itself is created when the
// first post or account is written.
func probeWritable(target, label string) error {
parent := filepath.Dir(target)
probeDir := nearestExisting(parent)
probe, err := os.CreateTemp(probeDir, ".volumen-write-probe-*")
if err != nil {
return errorf("%s is not writable at %q: %v", label, target, err)
}
name := probe.Name()
probe.Close()
os.Remove(name)
return nil
}
// nearestExisting returns the closest existing ancestor of path, which is
// where writability is probed.
func nearestExisting(path string) string {
for dir := path; ; {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return dir
}
dir = parent
}
}
// TemplatesDir returns the directory that holds templates.toml and
// tokens.toml, beside users_file.
func (c *Config) TemplatesDir() string {
return filepath.Dir(c.UsersFile)
}
// SecretKeyFile returns the path of the session secret the server
// generates for itself, kept beside the state files. [admin].session_key
// overrides it.
func (c *Config) SecretKeyFile() string {
return filepath.Join(filepath.Dir(c.UsersFile), "secret.key")
}
// TrimmedBaseURL returns [site].base_url without a trailing slash.
func (c *Config) TrimmedBaseURL() string {
return strings.TrimRight(c.Site.BaseURL, "/")
}
+388
View File
@@ -0,0 +1,388 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package config
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func writableConfig(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
content := filepath.Join(dir, "posts")
users := filepath.Join(dir, "users.toml")
body := "content_dir = \"" + content + "\"\n" +
"users_file = \"" + users + "\"\n" +
"\n[server]\n" +
"host = \"::1\"\n" +
"port = 8080\n" +
"env = \"production\"\n" +
"trust_proxy = true\n" +
"\n[site]\n" +
"base_url = \"https://site.example\"\n" +
"language = \"cs\"\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
func TestLoadMergesDefaults(t *testing.T) {
cfg, err := Load(writableConfig(t), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Host != "::1" || cfg.Server.Port != 8080 || cfg.Server.Env != EnvProduction {
t.Fatalf("server section wrong: %s %d %s", cfg.Server.Host, cfg.Server.Port, cfg.Server.Env)
}
if !cfg.Server.TrustProxy {
t.Fatal("trust_proxy not loaded")
}
if cfg.Site.Title != DefaultSiteTitle {
t.Fatalf("default title missing: %q", cfg.Site.Title)
}
if cfg.Site.Language != "cs" {
t.Fatalf("language = %q", cfg.Site.Language)
}
if cfg.Admin.SessionTTL != DefaultSessionTTL {
t.Fatalf("session_ttl = %d", cfg.Admin.SessionTTL)
}
if cfg.RevisionLimit != DefaultRevisionLimit {
t.Fatalf("revision_limit = %d", cfg.RevisionLimit)
}
}
func TestLoadMissingFileUsesDefaults(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "nope.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Host != DefaultHost || cfg.Server.Port != DefaultPort {
t.Fatalf("defaults not applied: %s %d", cfg.Server.Host, cfg.Server.Port)
}
}
func TestLoadRejectsInvalidToml(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("not = valid = toml"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := Load(path, Overrides{Port: -1}); err == nil {
t.Fatal("want parse error")
}
}
// A root key written below [server] belongs to that table, where nothing
// reads it. The loader names the key and the fix rather than silently
// falling back to the default path.
func TestLoadRejectsAFoldedRootKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
body := "[server]\nhost = \"::1\"\ncontent_dir = \"" + filepath.Join(dir, "posts") + "\"\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
_, err := Load(path, Overrides{Port: -1})
if err == nil {
t.Fatal("want an error for a root key below a table header")
}
if !strings.Contains(err.Error(), "content_dir") ||
!strings.Contains(err.Error(), "[table] header") {
t.Fatalf("error does not name the key and the fix: %v", err)
}
}
// The shipped template is a valid configuration as written: loading it
// and validating it (with the data paths pointed at a writable directory)
// is what every deployment does after copying config.toml.example.
func TestShippedTemplateLoads(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte(Template), 0o600); err != nil {
t.Fatalf("write template: %v", err)
}
cfg, err := Load(path, Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
// The template's site block must carry the same defaults the code
// serves, so a deployment with no config file and one that copies the
// example present the same site.
if cfg.Site.Title != DefaultSiteTitle || cfg.Site.Description != DefaultSiteDescription {
t.Fatalf("template defaults = %q, %q; want %q, %q",
cfg.Site.Title, cfg.Site.Description, DefaultSiteTitle, DefaultSiteDescription)
}
// A fresh template leaves the session key empty: the server generates
// its own secret rather than the config carrying one.
if cfg.Admin.SessionKey != "" {
t.Fatalf("template session_key should default empty, got %q", cfg.Admin.SessionKey)
}
}
// A missing configuration file moves the state under the user's home:
// that is what lets a plain `volumen serve` on a fresh machine run
// without root, and the overrides still win over it.
func TestMissingFileUsesUserStatePaths(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
cfg, err := Load(filepath.Join(home, "nope", "config.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := filepath.Join(home, ".local", "share", "volumen")
if cfg.ContentDir != filepath.Join(want, "posts") || cfg.UsersFile != filepath.Join(want, "users.toml") {
t.Fatalf("paths = %q %q, want under %q", cfg.ContentDir, cfg.UsersFile, want)
}
// XDG_DATA_HOME wins over ~/.local/share when set.
data := t.TempDir()
t.Setenv("XDG_DATA_HOME", data)
cfg, err = Load(filepath.Join(home, "nope", "config.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ContentDir != filepath.Join(data, "volumen", "posts") {
t.Fatalf("XDG_DATA_HOME ignored: %q", cfg.ContentDir)
}
// An explicit override beats the user default.
cfg, err = Load(filepath.Join(home, "nope", "config.toml"), Overrides{
Port: -1,
ContentDir: "/srv/posts",
UsersFile: "/srv/users.toml",
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ContentDir != "/srv/posts" || cfg.UsersFile != "/srv/users.toml" {
t.Fatalf("override lost: %q %q", cfg.ContentDir, cfg.UsersFile)
}
}
// Without --config the file is chosen in order: /etc, then the per-user
// path, then /etc again so a fresh machine loads the defaults.
func TestResolveConfigPathOrder(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
userCfg := filepath.Join(home, ".config", "volumen", "config.toml")
// Nothing exists: the system path is named so Load reports defaults.
if got := ResolveConfigPath(); got != DefaultPath {
t.Fatalf("with no files = %q, want %q", got, DefaultPath)
}
if err := os.MkdirAll(filepath.Dir(userCfg), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(userCfg, []byte("port = 9091\n"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if got := ResolveConfigPath(); got != userCfg {
t.Fatalf("user config not found: %q", got)
}
}
func TestOverrides(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "x.toml"), Overrides{
Host: "127.0.0.1",
Port: 1234,
ContentDir: "/srv/posts",
UsersFile: "/srv/users.toml",
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Host != "127.0.0.1" || cfg.Server.Port != 1234 {
t.Fatalf("host/port override failed: %s %d", cfg.Server.Host, cfg.Server.Port)
}
if cfg.ContentDir != "/srv/posts" || cfg.UsersFile != "/srv/users.toml" {
t.Fatal("path overrides failed")
}
}
func TestValidateOK(t *testing.T) {
cfg, err := Load(writableConfig(t), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
}
func TestValidateFailures(t *testing.T) {
dir := t.TempDir()
base := func(mutate func(*Config)) *Config {
cfg, err := Load(filepath.Join(dir, "none.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if mutate != nil {
mutate(cfg)
}
return cfg
}
cases := []struct {
name string
cfg *Config
frag string
}{
{"host", base(func(c *Config) { c.Server.Host = "" }), "[server].host"},
{"host_not_an_address", base(func(c *Config) { c.Server.Host = "localhost" }), "[server].host"},
{"port", base(func(c *Config) { c.Server.Port = 0 }), "[server].port"},
{"env", base(func(c *Config) { c.Server.Env = "staging" }), "[server].env"},
{"log_format", base(func(c *Config) { c.Server.LogFormat = "xml" }), "log_format"},
{"trusted_proxies", base(func(c *Config) { c.Server.TrustedProxies = []string{"not a prefix"} }), "trusted_proxies"},
{"base_url", base(func(c *Config) { c.Site.BaseURL = "notaurl" }), "base_url"},
{"fediverse", base(func(c *Config) { c.Site.FediverseCreator = "nope" }), "fediverse_creator"},
{"webhook", base(func(c *Config) { c.Webhooks = []Webhook{{URL: "ftp://x"}} }), "webhooks"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.cfg.Validate()
if err == nil {
t.Fatal("want error")
}
if !strings.Contains(err.Error(), tc.frag) {
t.Fatalf("error %q does not mention %q", err, tc.frag)
}
})
}
t.Run("session_ttl", func(t *testing.T) {
cfg := base(func(c *Config) { c.Admin.SessionTTL = 0 })
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "session_ttl") {
t.Fatalf("err = %v", err)
}
})
t.Run("password lengths", func(t *testing.T) {
cfg := base(func(c *Config) {
c.Admin.MinPasswordLength = 20
c.Admin.MaxPasswordLength = 10
})
if err := cfg.Validate(); err == nil {
t.Fatal("want error")
}
})
t.Run("rate limit", func(t *testing.T) {
cfg := base(func(c *Config) { c.API.RateLimit = -1 })
if err := cfg.Validate(); err == nil {
t.Fatal("want error")
}
})
}
// The config file an operator writes for a reverse proxy decodes its
// trusted proxy list, empty list included: this is the shape documented
// in the template and what a production deployment relies on.
func TestTrustedProxiesParse(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
body := "content_dir = \"" + filepath.Join(dir, "posts") + "\"\n" +
"users_file = \"" + filepath.Join(dir, "users.toml") + "\"\n" +
"\n[server]\n" +
"host = \"::1\"\n" +
"trust_proxy = true\n" +
"trusted_proxies = [\"::1\", \"127.0.0.1\"]\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
cfg, err := Load(path, Overrides{Port: PortUnset})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := []string{"::1", "127.0.0.1"}
if !slices.Equal(cfg.Server.TrustedProxies, want) {
t.Fatalf("trusted_proxies = %v, want %v", cfg.Server.TrustedProxies, want)
}
emptyPath := filepath.Join(dir, "empty.toml")
emptyBody := "content_dir = \"" + filepath.Join(dir, "posts") + "\"\n" +
"users_file = \"" + filepath.Join(dir, "users.toml") + "\"\n" +
"\n[server]\n" +
"host = \"::1\"\n" +
"trusted_proxies = []\n"
if err := os.WriteFile(emptyPath, []byte(emptyBody), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
empty, err := Load(emptyPath, Overrides{Port: PortUnset})
if err != nil {
t.Fatalf("Load empty: %v", err)
}
if len(empty.Server.TrustedProxies) != 0 {
t.Fatalf("empty list parsed as %v", empty.Server.TrustedProxies)
}
}
// The example shipped in the repository is the embedded template, so the
// two cannot drift: the documentation points at both.
func TestExampleMatchesTemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "config.toml.example"))
if err != nil {
t.Fatalf("read config.toml.example: %v", err)
}
if string(raw) != Template {
t.Fatal("config.toml.example differs from config.Template")
}
}
func TestAuditLogPathAndScheduler(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "x.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AuditLog != "" {
t.Fatal("audit log should default to disabled")
}
if cfg.Scheduler.Enabled || cfg.Scheduler.Interval != DefaultSchedulerInterval {
t.Fatal("scheduler defaults wrong")
}
cfg.AuditLog = "/var/log/volumen-audit.log"
cfg.Scheduler = Scheduler{Enabled: true, Interval: 60}
if cfg.AuditLog != "/var/log/volumen-audit.log" {
t.Fatalf("audit log = %q", cfg.AuditLog)
}
if !cfg.Scheduler.Enabled || cfg.Scheduler.Interval != 60 {
t.Fatal("scheduler config wrong")
}
}
// A hook is on unless the file turns it off, which is the reason the
// field is a pointer: an omitted key must not read as false.
func TestWebhookDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "hooks.toml")
body := "[[webhooks]]\nurl = \"https://example.com/one\"\n\n" +
"[[webhooks]]\nurl = \"https://example.com/two\"\nenabled = false\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
cfg, err := Load(path, Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Webhooks) != 2 {
t.Fatalf("webhooks = %v", cfg.Webhooks)
}
if !cfg.Webhooks[0].Delivers() {
t.Fatal("an omitted enabled key disabled the hook")
}
if cfg.Webhooks[1].Delivers() {
t.Fatal("an explicit false left the hook enabled")
}
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package config
// Template is the commented configuration file every deployment starts
// from: an operator copies it to the config path and edits it. The
// committed config.toml.example is this constant, unchanged, and a test
// asserts that; the release pipeline ships that file as an asset.
//
// The root keys come first, before any table header: a key written below
// a header belongs to that table, and the loader refuses a root key that
// a header swallowed rather than silently falling back to the default.
// Every key this file accepts is listed here, and docs/CONFIGURATION.md
// is the reference for its type, default and rules.
const Template = `# volumen configuration.
#
# Every key this file accepts is listed here. Copy it to
# /etc/volumen/config.toml (or ~/.config/volumen/config.toml for a
# per-user installation), then edit.
#
# Keys that belong to no table come first, because a key written below a
# [table] header belongs to that table.
# Directory of the Markdown posts (.md with TOML frontmatter).
content_dir = "/var/lib/volumen/posts"
# File holding the admin accounts (managed from the admin Settings page).
users_file = "/var/lib/volumen/users.toml"
# How many previous versions of each post to keep in .revisions/
# (0 keeps none, which also makes deleting a post permanent).
revision_limit = 10
# Where the audit log is appended, or "" to disable auditing. Records
# who changed what, and when, in JSON lines.
audit_log = ""
[server]
# Address to bind, as an IP address: "::" is every interface, "::1" is
# loopback only, which is what a reverse proxy needs.
host = "::"
port = 9091
# Environment label: "development" or "production". It decides the
# startup safety checks (session key length, cookie flags, password
# policy).
env = "development"
# Set true ONLY when a trusted reverse proxy terminates TLS in front of
# volumen. Client addresses are then taken from X-Forwarded-For and
# cookies are marked Secure.
trust_proxy = false
# Addresses whose X-Forwarded-For may be believed, as addresses or CIDR
# prefixes. An empty list never reads the header and always uses the
# connection address; list the proxy so its clients each rate-limit
# under their own address.
trusted_proxies = []
# Set true in production to force the Secure flag on session cookies.
cookie_secure = false
# Log output format: "text" (human readable) or "json" (structured).
log_format = "text"
[site]
title = "Volumen"
description = "Powered by Volumen."
# Absolute URL of the public site, without a trailing slash.
base_url = "https://example.com"
language = "en"
author = "Anonymous"
# Fediverse handle surfaced as the author in feeds and meta tags.
# Leave empty to disable.
fediverse_creator = ""
[admin]
# Secret that signs session cookies (at least 64 bytes in production).
# Leave empty: the server generates one and keeps it in secret.key next
# to users.toml. A value here overrides that file.
session_key = ""
# Session lifetime in seconds (24 hours by default).
session_ttl = 86400
# Minimum password length enforced when a password is set in the admin UI.
min_password_length = 10
# Maximum password length, to bound the scrypt work.
max_password_length = 1024
# Maximum upload size in bytes (10 MB by default).
max_upload_bytes = 10485760
[api]
# Public API rate limit: requests allowed per window per client address.
# 0 disables rate limiting.
rate_limit = 60
# Rate-limit window length in seconds.
rate_limit_window = 60
# Scheduled publishing, for a post whose frontmatter carries publish_at.
# [scheduler]
# enabled = false
# interval = 300
# Outgoing webhooks: POST a signed JSON payload on post changes so a
# front-end can rebuild its cache or static pages. Repeat the block for
# more endpoints; events may be omitted to receive every event.
# [[webhooks]]
# url = "https://example.com/hooks/rebuild"
# secret = "a-long-random-string" # HMAC-SHA256 signing key
# events = ["post.created", "post.updated", "post.deleted", "post.published"]
# enabled = true
`