feat: initialize nuntius project with full server implementation
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
//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
|
||||
}
|
||||
Reference in New Issue
Block a user