Files

235 lines
6.3 KiB
Go
Raw Permalink Normal View History

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package config loads nuntius configuration from a TOML file.
//
// Configuration supports env var expansion for secrets:
// 1. A TOML file (config.toml) — checked into the repo or provisioned
// per environment. Holds non-secret defaults and references to env vars.
// 2. Environment variables — used for secrets like SMTP passwords.
// Reference them in the TOML file as ${VAR_NAME} or $VAR_NAME.
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sourcedock.dev/petrbalvin/interpres"
)
// ValidFormTypes lists the form types nuntius knows how to handle.
// Each form's `type` field is validated against this list in validate().
var ValidFormTypes = map[string]bool{
"contact": true,
"feedback": true,
"newsletter": true,
"generic": true,
}
// Config is the root configuration for nuntius.
type Config struct {
Server ServerConfig `toml:"server"`
Forms []Form `toml:"forms"`
DataDir string `toml:"data_dir"`
}
// ServerConfig holds server-wide settings.
type ServerConfig struct {
Port int `toml:"port"`
}
// Form is one contact / feedback / newsletter endpoint.
type Form struct {
Name string `toml:"name"`
Path string `toml:"path"`
Type string `toml:"type"`
SMTP SMTPConfig `toml:"smtp"`
To string `toml:"to"`
From string `toml:"from"`
RateLimitPerHour int `toml:"rate_limit_per_hour"`
HoneypotField string `toml:"honeypot_field"`
AllowedOrigins []string `toml:"allowed_origins"`
}
// SMTPConfig holds SMTP credentials and connection details.
type SMTPConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
User string `toml:"user"`
Password string `toml:"password"`
}
// Load reads, expands env vars in, and parses the TOML file at path.
// If the file does not exist, a default template is created first.
func Load(path string) (*Config, error) {
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
}
if err := os.WriteFile(path, defaultConfig, 0644); err != nil {
return nil, fmt.Errorf("write default config to %s: %w", path, err)
}
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
// Step 1: expand ${VAR_NAME} references in the raw text.
expanded := os.Expand(string(raw), os.Getenv)
// Step 2: parse TOML, rejecting unknown fields to catch typos early.
var cfg Config
if err := interpres.NewDecoder().DisallowUnknownFields().Decode([]byte(expanded), &cfg); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if err := cfg.validate(); err != nil {
return nil, err
}
return &cfg, nil
}
// validate runs sanity checks on the loaded config and applies defaults.
func (c *Config) validate() error {
if c.Server.Port == 0 {
c.Server.Port = 8080
}
if c.DataDir == "" {
c.DataDir = "./data"
}
if len(c.Forms) == 0 {
return fmt.Errorf("at least one form must be defined under `forms`")
}
paths := make(map[string]string, len(c.Forms))
for i := range c.Forms {
f := &c.Forms[i]
if f.Name == "" {
return fmt.Errorf("form #%d: name is required", i+1)
}
if f.Path == "" {
return fmt.Errorf("form %q: path is required", f.Name)
}
if f.Type == "" {
c.Forms[i].Type = "contact"
}
if !ValidFormTypes[c.Forms[i].Type] {
supported := make([]string, 0, len(ValidFormTypes))
for k := range ValidFormTypes {
supported = append(supported, k)
}
return fmt.Errorf("form %q: type %q is not supported (must be one of: %s)",
f.Name, c.Forms[i].Type, strings.Join(supported, ", "))
}
if !strings.HasPrefix(f.Path, "/") {
return fmt.Errorf("form %q: path must start with /", f.Name)
}
if existing, ok := paths[f.Path]; ok {
return fmt.Errorf("form %q: duplicate path %q (also used by %q)", f.Name, f.Path, existing)
}
paths[f.Path] = f.Name
if f.SMTP.Host == "" {
return fmt.Errorf("form %q: smtp.host is required", f.Name)
}
if f.SMTP.Port == 0 {
return fmt.Errorf("form %q: smtp.port is required", f.Name)
}
if f.SMTP.User == "" {
return fmt.Errorf("form %q: smtp.user is required", f.Name)
}
if f.To == "" {
return fmt.Errorf("form %q: `to` is required", f.Name)
}
if f.From == "" {
// Default From to the SMTP user.
c.Forms[i].From = f.SMTP.User
}
if f.RateLimitPerHour == 0 {
c.Forms[i].RateLimitPerHour = 10
}
if f.HoneypotField == "" {
c.Forms[i].HoneypotField = "website"
}
}
return nil
}
// ConfigPath returns the canonical path for nuntius configuration.
func ConfigPath() string {
return "/etc/nuntius/config.toml"
}
// AddrFor returns "host:port" for the given SMTP config.
func (s SMTPConfig) AddrFor() string {
return s.Host + ":" + strconv.Itoa(s.Port)
}
// defaultConfig is written to disk when no config exists yet.
var defaultConfig = []byte(`# nuntius configuration.
#
# Secrets (SMTP passwords) are referenced as ${VAR_NAME} and expanded from the
# environment at startup, so they never live in this file.
data_dir = "./data"
[server]
port = 8080
[[forms]]
name = "contact"
type = "contact"
path = "/api/nuntius/contact"
to = "you@example.com"
from = "contact@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
[[forms]]
name = "feedback"
type = "feedback"
path = "/api/nuntius/feedback"
to = "you@example.com"
from = "contact@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
[[forms]]
name = "newsletter"
type = "newsletter"
path = "/api/nuntius/newsletter"
to = "you@example.com"
from = "contact@example.com"
rate_limit_per_hour = 100
honeypot_field = "bot_email"
allowed_origins = ["https://example.com"]
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
`)