// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT //go:build linux || freebsd package handler import ( "crypto/subtle" "encoding/json" "log/slog" "net/http" "strings" "sync" "sourcedock.dev/petrbalvin/nuntius/internal/config" ) // Canonical metric names. The same identifiers serve as counter keys in // bump() and as JSON field names in GET /metrics responses. const ( metricReceived = "received" metricHoneypotBlocked = "honeypot_blocked" metricRateLimited = "rate_limited" metricOriginBlocked = "origin_blocked" metricBodyTooLarge = "body_too_large" metricInvalidBody = "invalid_body" metricValidationFailed = "validation_failed" metricSendFailed = "send_failed" metricPersistFailed = "persist_failed" metricDuplicateSignup = "duplicate_signup" metricConfirmationSent = "confirmation_sent" metricConfirmed = "confirmed" metricConfirmFailed = "confirmation_failed" metricSent = "sent" metricAutoReplyFailed = "auto_reply_failed" metricTelegramFailed = "telegram_failed" ) // metricNames lists every counter in stable order so totals and snapshots // cannot drift from the struct fields. var metricNames = []string{ metricReceived, metricHoneypotBlocked, metricRateLimited, metricOriginBlocked, metricBodyTooLarge, metricInvalidBody, metricValidationFailed, metricSendFailed, metricPersistFailed, metricDuplicateSignup, metricConfirmationSent, metricConfirmed, metricConfirmFailed, metricSent, metricAutoReplyFailed, metricTelegramFailed, } // FormStats holds lifetime counters for one form. Every field counts // outcomes of requests routed to that form's endpoints. type FormStats struct { Received int64 `json:"received"` HoneypotBlocked int64 `json:"honeypot_blocked"` RateLimited int64 `json:"rate_limited"` OriginBlocked int64 `json:"origin_blocked"` BodyTooLarge int64 `json:"body_too_large"` InvalidBody int64 `json:"invalid_body"` ValidationFailed int64 `json:"validation_failed"` SendFailed int64 `json:"send_failed"` PersistFailed int64 `json:"persist_failed"` DuplicateSignup int64 `json:"duplicate_signup"` ConfirmationSent int64 `json:"confirmation_sent"` Confirmed int64 `json:"confirmed"` ConfirmFailed int64 `json:"confirmation_failed"` Sent int64 `json:"sent"` AutoReplyFailed int64 `json:"auto_reply_failed"` TelegramFailed int64 `json:"telegram_failed"` } // incByIndex increments the counter at metricNames[i]; indexes outside the // known set are ignored. func (f *FormStats) incByIndex(i int) { switch i { case 0: f.Received++ case 1: f.HoneypotBlocked++ case 2: f.RateLimited++ case 3: f.OriginBlocked++ case 4: f.BodyTooLarge++ case 5: f.InvalidBody++ case 6: f.ValidationFailed++ case 7: f.SendFailed++ case 8: f.PersistFailed++ case 9: f.DuplicateSignup++ case 10: f.ConfirmationSent++ case 11: f.Confirmed++ case 12: f.ConfirmFailed++ case 13: f.Sent++ case 14: f.AutoReplyFailed++ case 15: f.TelegramFailed++ } } // add sums another snapshot into f. func (f *FormStats) add(other FormStats) { for i := range metricNames { switch i { case 0: f.Received += other.Received case 1: f.HoneypotBlocked += other.HoneypotBlocked case 2: f.RateLimited += other.RateLimited case 3: f.OriginBlocked += other.OriginBlocked case 4: f.BodyTooLarge += other.BodyTooLarge case 5: f.InvalidBody += other.InvalidBody case 6: f.ValidationFailed += other.ValidationFailed case 7: f.SendFailed += other.SendFailed case 8: f.PersistFailed += other.PersistFailed case 9: f.DuplicateSignup += other.DuplicateSignup case 10: f.ConfirmationSent += other.ConfirmationSent case 11: f.Confirmed += other.Confirmed case 12: f.ConfirmFailed += other.ConfirmFailed case 13: f.Sent += other.Sent case 14: f.AutoReplyFailed += other.AutoReplyFailed case 15: f.TelegramFailed += other.TelegramFailed } } } // formStatsRegistry guards the per-form counters shared between request // goroutines and the /metrics endpoint. type formStatsRegistry struct { mu sync.Mutex stats map[string]*FormStats } func newFormStatsRegistry(forms map[string]*config.Form) *formStatsRegistry { r := &formStatsRegistry{stats: make(map[string]*FormStats, len(forms))} for path := range forms { r.stats[path] = &FormStats{} } return r } // bump increments the named counter for a form. Unknown paths or metrics // are dropped silently so logging can never fail a request. func (r *formStatsRegistry) bump(path, metric string) { if r == nil { return } r.mu.Lock() defer r.mu.Unlock() fs, ok := r.stats[path] if !ok { return } for i, name := range metricNames { if name == metric { fs.incByIndex(i) return } } } // snapshot returns a copy of every form's counters plus their sum. func (r *formStatsRegistry) snapshot() (map[string]FormStats, FormStats) { out := make(map[string]FormStats, len(r.stats)) var total FormStats r.mu.Lock() defer r.mu.Unlock() for path, fs := range r.stats { out[path] = *fs total.add(*fs) } return out, total } // Metrics serves GET /metrics: lifetime counters per form and combined // totals, as JSON. The endpoint is exempt from rate limiting and sends no // CORS headers, so third-party pages cannot read submission volumes. With // server.metrics_token set it requires that token as a bearer credential. func (h *ContactHandler) Metrics(w http.ResponseWriter, r *http.Request) { if h.metricsToken != "" { token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(h.metricsToken)) != 1 { w.Header().Set("WWW-Authenticate", `Bearer realm="nuntius metrics"`) respondError(w, http.StatusUnauthorized, "unauthorized", "A valid bearer token is required.") return } } if h.stats == nil { w.WriteHeader(http.StatusNotFound) return } formSnapshots, total := h.stats.snapshot() w.Header().Set("Content-Type", "application/json; charset=utf-8") if err := json.NewEncoder(w).Encode(map[string]any{ "totals": total, "forms": formSnapshots, }); err != nil { slog.Error("failed to encode metrics response", "err", err) } } // bump records one occurrence of metric for the given form path. func (h *ContactHandler) bump(path, metric string) { h.stats.bump(path, metric) }