//go:build linux || freebsd package handler import ( "encoding/json" "log/slog" "net/http" "path/filepath" "strings" "sync" "time" "codeberg.org/petrbalvin/nuntius/internal/config" "codeberg.org/petrbalvin/nuntius/internal/email" "codeberg.org/petrbalvin/nuntius/internal/storage" "codeberg.org/petrbalvin/nuntius/pkg/contactform" ) // 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]*email.FormSender rateLimits map[string]*rateLimiter stores map[string]*storage.NewsletterStore } // 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]*email.FormSender, len(cfg.Forms)), rateLimits: make(map[string]*rateLimiter, len(cfg.Forms)), stores: make(map[string]*storage.NewsletterStore, 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) _ = json.NewEncoder(w).Encode(contactform.ErrorResponse{ Error: "validation", Details: errs, }) 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") _ = json.NewEncoder(w).Encode(map[string]any{ "status": "ok", "forms": len(h.forms), }) } // --- 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 { return &rateLimiter{ perHour: perHour, buckets: make(map[string]*bucket), } } 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") _ = json.NewEncoder(w).Encode(contactform.Response{OK: true}) } func respondError(w http.ResponseWriter, code int, err, msg string) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(contactform.ErrorResponse{ Error: err, Message: msg, }) } 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 }