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