feat: contact form backend for linux and freebsd servers
Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s
Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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)
|
||||
}
|
||||
Reference in New Issue
Block a user