Files
hotdog-cms/internal/endpoint/config.go
T

202 lines
6.1 KiB
Go

// Package endpoint is the one part of hotdog-cms that runs per request: it takes
// form submissions and answers searches for the sites it is configured with.
// It holds no content of its own and needs no database. It is not the CMS:
// it never edits a site, and the CMS never runs here.
package endpoint
import (
"bytes"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/about"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forms"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
)
// Config is endpoint.yaml.
type Config struct {
Listen string `yaml:"listen"`
// TrustedProxies are the peers whose X-Forwarded-For is believed, such
// as the reverse proxy in front. Anyone else is identified by their own
// address, whatever headers they send.
TrustedProxies []string `yaml:"trusted_proxies"`
SMTP SMTPConfig `yaml:"smtp"`
RateLimit RateLimit `yaml:"rate_limit"`
Sites []SiteConfig `yaml:"sites"`
// Viewer lets the editor read stored submissions (viewer.go).
Viewer ViewerConfig `yaml:"viewer"`
// Source is where people using the endpoint can get its source (the
// AGPL's section 13), answered at /_hotdog/source. Leave it out when
// running HotDog CMS as released; set it if you've changed the code.
Source string `yaml:"source"`
}
// SMTPConfig is how submissions are emailed.
type SMTPConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
PasswordEnv string `yaml:"password_env"` // the variable holding the password; default HOTDOG_SMTP_PASSWORD
From string `yaml:"from"`
// TLS: starttls (the default), tls (port 465 style), or none, which is
// only allowed to a server on this machine.
TLS string `yaml:"tls"`
}
// RateLimit caps submissions per visitor address.
type RateLimit struct {
Count int `yaml:"count"` // default 5
Window time.Duration `yaml:"window"` // default 10m
}
// SiteConfig is one site the endpoint serves.
type SiteConfig struct {
Dir string `yaml:"dir"` // the site's source: site.yaml, forms/, layouts/
Public string `yaml:"public"` // the built site, for search indexes
Hosts []string `yaml:"hosts"` // default: the host in site.yaml's url, and www.
Store string `yaml:"store"` // folder for forms with store: true; JSON lines, one file per form
// Recipients are the only addresses this site's forms may email:
// "[email protected]", or "@example.org" for any address there. The
// forms themselves live in the site, which anyone who can write to it
// can change; this list is yours.
Recipients []string `yaml:"recipients"`
}
// allowed says whether a form may send to addr.
func (sc SiteConfig) allowed(addr string) bool {
if len(sc.Recipients) == 0 {
return true
}
addr = strings.ToLower(strings.TrimSpace(addr))
for _, r := range sc.Recipients {
r = strings.ToLower(strings.TrimSpace(r))
if addr == r || (strings.HasPrefix(r, "@") && strings.HasSuffix(addr, r)) {
return true
}
}
return false
}
type siteRuntime struct {
cfg SiteConfig
site *site.Config
forms map[string]*forms.Form
hosts []string
}
// LoadConfig reads endpoint.yaml and every site it names.
func LoadConfig(file string) (*Config, map[string]*siteRuntime, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, nil, err
}
var c Config
dec := yaml.NewDecoder(bytes.NewReader(raw))
dec.KnownFields(true)
if err := dec.Decode(&c); err != nil {
return nil, nil, fmt.Errorf("%s: %w", file, err)
}
if c.Listen == "" {
c.Listen = "127.0.0.1:8180"
}
if c.Source != "" && !about.CheckURL(c.Source) {
return nil, nil, fmt.Errorf("%s: source %q is not a web address", file, c.Source)
}
if c.RateLimit.Count <= 0 {
c.RateLimit.Count = 5
}
if c.RateLimit.Window <= 0 {
c.RateLimit.Window = 10 * time.Minute
}
if c.SMTP.PasswordEnv == "" {
c.SMTP.PasswordEnv = "HOTDOG_SMTP_PASSWORD"
}
switch c.SMTP.TLS {
case "":
c.SMTP.TLS = "starttls"
case "starttls", "tls":
case "none":
if !isLoopbackHost(c.SMTP.Host) {
return nil, nil, fmt.Errorf("%s: smtp tls: none is only allowed to a server on this machine", file)
}
default:
return nil, nil, fmt.Errorf("%s: smtp tls must be starttls, tls or none", file)
}
for _, p := range c.TrustedProxies {
if _, _, err := net.ParseCIDR(p); err != nil {
return nil, nil, fmt.Errorf("%s: trusted_proxies %q is not a network like 127.0.0.1/32", file, p)
}
}
sites := map[string]*siteRuntime{}
for i, sc := range c.Sites {
sc.Dir = abs(file, sc.Dir)
if sc.Public != "" {
sc.Public = abs(file, sc.Public)
}
if sc.Store != "" {
sc.Store = abs(file, sc.Store)
}
scfg, err := site.LoadConfig(sc.Dir)
if err != nil {
return nil, nil, fmt.Errorf("%s: sites[%d]: %w", file, i, err)
}
fs, err := forms.Load(sc.Dir)
if err != nil {
return nil, nil, fmt.Errorf("%s: sites[%d]: %w", file, i, err)
}
hosts := sc.Hosts
if len(hosts) == 0 {
u, _ := url.Parse(scfg.URL)
h := strings.TrimPrefix(u.Hostname(), "www.")
hosts = []string{h, "www." + h}
}
for _, f := range fs {
if f.To != "" && !sc.allowed(f.To) {
return nil, nil, fmt.Errorf("%s: %s: form %s sends to %s, which isn't in this site's recipients", file, sc.Dir, f.Name, f.To)
}
if f.Store && sc.Store == "" {
return nil, nil, fmt.Errorf("%s: %s: form %s has store: true but the site has no store folder", file, sc.Dir, f.Name)
}
}
rt := &siteRuntime{cfg: sc, site: scfg, forms: fs, hosts: hosts}
for _, h := range hosts {
h = strings.ToLower(h)
if _, dup := sites[h]; dup {
return nil, nil, fmt.Errorf("%s: host %s is claimed by two sites", file, h)
}
sites[h] = rt
}
}
if len(sites) == 0 {
return nil, nil, fmt.Errorf("%s: no sites", file)
}
if err := c.Viewer.check(); err != nil {
return nil, nil, fmt.Errorf("%s: %w", file, err)
}
return &c, sites, nil
}
func abs(configFile, p string) string {
if filepath.IsAbs(p) {
return p
}
return filepath.Join(filepath.Dir(configFile), p)
}
func isLoopbackHost(h string) bool {
if h == "localhost" {
return true
}
ip := net.ParseIP(h)
return ip != nil && ip.IsLoopback()
}