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,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, "/")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
Reference in New Issue
Block a user