Files

318 lines
8.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 handler
import (
"encoding/json"
"log/slog"
"net/http"
"path/filepath"
"strings"
"sync"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
"sourcedock.dev/petrbalvin/nuntius/internal/email"
"sourcedock.dev/petrbalvin/nuntius/internal/storage"
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
)
// formSender is the interface for delivering a submission.
type formSender interface {
Send(req contactform.Request) error
}
// subscriberStorer persists newsletter subscribers.
// storage.NewsletterStore satisfies this interface.
type subscriberStorer interface {
Append(sub storage.Subscriber) error
}
// ContactHandler serves one or more contact forms, dispatched by URL path.
// Each form has its own sender, rate limiter, CORS allowlist, and honeypot.
// Newsletter-type forms also get a per-form subscriber store.
type ContactHandler struct {
forms map[string]*config.Form
senders map[string]formSender
rateLimits map[string]*rateLimiter
stores map[string]subscriberStorer
}
// New constructs a ContactHandler that serves all forms defined in cfg.
func New(cfg *config.Config) *ContactHandler {
h := &ContactHandler{
forms: make(map[string]*config.Form, len(cfg.Forms)),
senders: make(map[string]formSender, len(cfg.Forms)),
rateLimits: make(map[string]*rateLimiter, len(cfg.Forms)),
stores: make(map[string]subscriberStorer, len(cfg.Forms)),
}
for i := range cfg.Forms {
f := &cfg.Forms[i]
h.forms[f.Path] = f
h.senders[f.Path] = email.NewFormSender(f)
h.rateLimits[f.Path] = newRateLimiter(f.RateLimitPerHour)
if f.Type == "newsletter" {
storePath := filepath.Join(cfg.DataDir, "newsletter-"+f.Name+".jsonl")
h.stores[f.Path] = storage.NewNewsletterStore(storePath)
}
}
return h
}
// Register mounts one POST + OPTIONS handler per form, plus a single
// GET /health handler.
func (h *ContactHandler) Register(mux *http.ServeMux) {
for path := range h.forms {
mux.HandleFunc("POST "+path, h.makeHandler(path))
mux.HandleFunc("OPTIONS "+path, h.makeHandler(path))
}
mux.HandleFunc("GET /health", h.Health)
}
// makeHandler returns the per-form HTTP handler.
func (h *ContactHandler) makeHandler(path string) http.HandlerFunc {
form := h.forms[path]
sender := h.senders[path]
limiter := h.rateLimits[path]
store := h.stores[path] // nil for non-newsletter forms
return func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// CORS preflight.
if r.Method == http.MethodOptions {
if formAllowed(form, origin) {
writeCORS(w, origin, form.AllowedOrigins)
}
w.WriteHeader(http.StatusNoContent)
return
}
// CORS on actual request.
if formAllowed(form, origin) {
writeCORS(w, origin, form.AllowedOrigins)
} else if origin != "" {
respondError(w, http.StatusForbidden, "origin_not_allowed", "")
return
}
// Rate limit.
if form.RateLimitPerHour > 0 {
ip := clientIP(r)
if !limiter.allow(ip) {
respondError(w, http.StatusTooManyRequests, "rate_limited", "Too many requests, please try again later.")
return
}
}
// Parse body.
defer r.Body.Close()
var req contactform.Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid_json", "Could not parse JSON body.")
return
}
// Honeypot: silently accept but never send.
if form.HoneypotField != "" && req.Honeypot != "" {
slog.Info("honeypot triggered, dropping silently",
"form", form.Name, "path", path, "ip", clientIP(r))
respondOK(w)
return
}
// Validate.
if errs := contactform.Validate(&req, form.Type); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(contactform.ErrorResponse{
Error: "validation",
Details: errs,
}); err != nil {
slog.Error("failed to encode json response", "err", err)
}
return
}
// Send email.
if err := sender.Send(req); err != nil {
slog.Error("send failed",
"err", err, "form", form.Name, "path", path, "ip", clientIP(r))
respondError(w, http.StatusInternalServerError, "send_failed", "Could not send email.")
return
}
// For newsletter forms, also persist the subscriber to disk.
if store != nil {
if err := store.Append(storage.Subscriber{
Email: req.Email,
IP: clientIP(r),
Form: form.Name,
}); err != nil {
slog.Error("newsletter store append failed",
"err", err, "form", form.Name, "path", path, "ip", clientIP(r))
respondError(w, http.StatusInternalServerError, "storage_failed",
"Could not record subscription.")
return
}
}
slog.Info("message sent",
"form", form.Name, "path", path,
"service", req.Service, "ip", clientIP(r),
)
respondOK(w)
}
}
// Health handles GET /health.
func (h *ContactHandler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"forms": len(h.forms),
}); err != nil {
slog.Error("failed to encode health response", "err", err)
}
}
// --- internals ---
func formAllowed(form *config.Form, origin string) bool {
if origin == "" {
return true
}
for _, a := range form.AllowedOrigins {
if a == origin {
return true
}
}
return false
}
func writeCORS(w http.ResponseWriter, origin string, allowed []string) {
// Only echo the origin back if it is in the allowlist.
for _, a := range allowed {
if a == origin {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
return
}
}
}
// rateLimiter is a per-IP token bucket.
type rateLimiter struct {
mu sync.Mutex
perHour int
buckets map[string]*bucket
}
type bucket struct {
tokens float64
last time.Time
}
func newRateLimiter(perHour int) *rateLimiter {
r := &rateLimiter{
perHour: perHour,
buckets: make(map[string]*bucket),
}
// Clean up entries older than twice the refill window every hour.
r.startCleanup(1*time.Hour, 2*time.Hour)
return r
}
// startCleanup launches a background goroutine that periodically removes
// expired bucket entries to prevent unbounded memory growth.
func (r *rateLimiter) startCleanup(interval, maxAge time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
r.cleanup(maxAge)
}
}()
}
func (r *rateLimiter) cleanup(maxAge time.Duration) {
r.mu.Lock()
defer r.mu.Unlock()
cutoff := time.Now().Add(-maxAge)
for ip, b := range r.buckets {
if b.last.Before(cutoff) {
delete(r.buckets, ip)
}
}
}
func (r *rateLimiter) allow(ip string) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
b, ok := r.buckets[ip]
if !ok {
b = &bucket{tokens: float64(r.perHour), last: now}
r.buckets[ip] = b
}
rate := float64(r.perHour) / 3600.0
elapsed := now.Sub(b.last).Seconds()
b.tokens = minF(b.tokens+elapsed*rate, float64(r.perHour))
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
func minF(a, b float64) float64 {
if a < b {
return a
}
return b
}
func respondOK(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(contactform.Response{OK: true}); err != nil {
slog.Error("failed to encode ok response", "err", err)
}
}
func respondError(w http.ResponseWriter, code int, err, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
if encErr := json.NewEncoder(w).Encode(contactform.ErrorResponse{
Error: err,
Message: msg,
}); encErr != nil {
slog.Error("failed to encode error response", "err", encErr)
}
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return xr
}
host := r.RemoteAddr
if i := strings.LastIndexByte(host, ':'); i >= 0 {
host = host[:i]
}
return host
}