2026-06-20 20:48:09 +02:00
|
|
|
//go:build linux || freebsd
|
|
|
|
|
|
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-07-26 20:29:01 +02:00
|
|
|
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
|
|
|
|
"sourcedock.dev/petrbalvin/nuntius/internal/email"
|
|
|
|
|
"sourcedock.dev/petrbalvin/nuntius/internal/storage"
|
|
|
|
|
"sourcedock.dev/petrbalvin/nuntius/pkg/contactform"
|
2026-06-20 20:48:09 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|