feat: initialize nuntius project with full server implementation
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
//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"
|
||||
|
||||
"codeberg.org/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}"
|
||||
`)
|
||||
@@ -0,0 +1,94 @@
|
||||
//go:build linux || freebsd
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadWritesAndParsesDefault(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
|
||||
// The first Load writes the default template, then parses it back.
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("Server.Port = %d, want 8080", cfg.Server.Port)
|
||||
}
|
||||
if cfg.DataDir != "./data" {
|
||||
t.Errorf("DataDir = %q, want ./data", cfg.DataDir)
|
||||
}
|
||||
if len(cfg.Forms) != 3 {
|
||||
t.Fatalf("len(Forms) = %d, want 3", len(cfg.Forms))
|
||||
}
|
||||
if cfg.Forms[0].Name != "contact" || cfg.Forms[0].SMTP.Host != "smtp.example.com" {
|
||||
t.Errorf("Forms[0] = %#v", cfg.Forms[0])
|
||||
}
|
||||
if cfg.Forms[2].RateLimitPerHour != 100 {
|
||||
t.Errorf("newsletter rate_limit_per_hour = %d, want 100", cfg.Forms[2].RateLimitPerHour)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExpandsEnvVars(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
doc := `
|
||||
data_dir = "./data"
|
||||
|
||||
[server]
|
||||
port = 9000
|
||||
|
||||
[[forms]]
|
||||
name = "contact"
|
||||
type = "contact"
|
||||
path = "/api/nuntius/contact"
|
||||
to = "you@example.com"
|
||||
|
||||
[forms.smtp]
|
||||
host = "smtp.example.com"
|
||||
port = 587
|
||||
user = "u@example.com"
|
||||
password = "${NUNTIUS_TEST_PW}"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(doc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("NUNTIUS_TEST_PW", "s3cret")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Forms[0].SMTP.Password != "s3cret" {
|
||||
t.Errorf("password = %q, want s3cret", cfg.Forms[0].SMTP.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownField(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
doc := `
|
||||
bogus = true
|
||||
|
||||
[server]
|
||||
port = 8080
|
||||
|
||||
[[forms]]
|
||||
name = "contact"
|
||||
path = "/api/nuntius/contact"
|
||||
to = "you@example.com"
|
||||
|
||||
[forms.smtp]
|
||||
host = "smtp.example.com"
|
||||
port = 587
|
||||
user = "u@example.com"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(doc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("expected an error for an unknown field")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user