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

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-29 00:32:56 +02:00
commit 3a38f00dc0
49 changed files with 10769 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package contactform provides the types and the declarative validation
// the form pipeline runs on.
package contactform
// Request is the JSON body sent to the contact endpoint.
type Request struct {
Name string `json:"name"`
Email string `json:"email"`
Service string `json:"service,omitempty"`
Message string `json:"message"`
}
// Response is the success response.
type Response struct {
OK bool `json:"ok"`
}
// FieldError describes a single validation failure.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ErrorResponse is returned for any non-2xx response.
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
Details []FieldError `json:"details,omitempty"`
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package contactform
import (
"fmt"
"net/mail"
"slices"
"strings"
"unicode/utf8"
)
// MinNameRunes is the default minimum allowed length of a name.
const MinNameRunes = 2
// MaxNameRunes is the default maximum allowed length of a name.
const MaxNameRunes = 100
// MinMessageRunes is the default minimum allowed length of a message body.
const MinMessageRunes = 10
// MaxMessageRunes is the default maximum allowed length of a message body.
const MaxMessageRunes = 5000
// ServiceAny is the services entry that accepts any service value.
const ServiceAny = "*"
// DefaultServices is the built-in service allow-list the contact preset
// applies when a form does not define its own list. The empty value is
// always accepted on top of whatever this list holds, because a form that
// offers no choice never sends the field at all.
var DefaultServices = []string{
"architecture",
"ai",
"infrastructure",
"software",
"unix",
"other",
}
// Policy is the declarative validation rule set for one form. The server
// builds it from the form's configuration; a library caller writes it
// directly. The zero value accepts any name and message, so every limit
// that matters must be set explicitly.
type Policy struct {
// RequireName and RequireMessage switch the length checks for the two
// free-text fields on and off. The email address is always required
// and always checked: every form delivers mail and needs a reply-to.
RequireName bool
RequireMessage bool
MinNameRunes int
MaxNameRunes int
MinMessageRunes int
MaxMessageRunes int
// Services is the allow-list for the optional service field. A nil
// list means the field is not validated at all; a non-nil list holds
// the accepted values, with the empty value always accepted and the
// ServiceAny entry lifting the restriction entirely.
Services []string
}
// Preset returns the built-in policy for a form type. An empty or unknown
// type falls back to the contact preset, which is also how the server
// treats an unconfigured type. Every preset carries the default length
// limits; the newsletter preset simply leaves both free-text fields out
// of the checks, so switching them on later starts from sane limits.
func Preset(formType string) Policy {
switch formType {
case "newsletter":
return Policy{
MinNameRunes: MinNameRunes,
MaxNameRunes: MaxNameRunes,
MinMessageRunes: MinMessageRunes,
MaxMessageRunes: MaxMessageRunes,
}
case "feedback", "generic":
return Policy{
RequireName: true,
RequireMessage: true,
MinNameRunes: MinNameRunes,
MaxNameRunes: MaxNameRunes,
MinMessageRunes: MinMessageRunes,
MaxMessageRunes: MaxMessageRunes,
}
default: // contact, and the fallback for every unknown type
return Policy{
RequireName: true,
RequireMessage: true,
MinNameRunes: MinNameRunes,
MaxNameRunes: MaxNameRunes,
MinMessageRunes: MinMessageRunes,
MaxMessageRunes: MaxMessageRunes,
Services: slices.Clone(DefaultServices),
}
}
}
// Validate normalises the request in place (trims whitespace from every
// text field) and checks it against p. It returns one entry per failed
// field; a nil slice means the request is valid.
func Validate(r *Request, p Policy) []FieldError {
r.Name = strings.TrimSpace(r.Name)
r.Email = strings.TrimSpace(r.Email)
r.Service = strings.TrimSpace(r.Service)
r.Message = strings.TrimSpace(r.Message)
var errs []FieldError
if p.RequireName {
errs = append(errs, checkRunes("name", r.Name, p.MinNameRunes, p.MaxNameRunes)...)
}
if _, err := mail.ParseAddress(r.Email); err != nil {
errs = append(errs, FieldError{Field: "email", Message: "email is invalid"})
}
if p.Services != nil && !serviceAllowed(p.Services, r.Service) {
errs = append(errs, FieldError{Field: "service", Message: "service is not a recognised value"})
}
if p.RequireMessage {
errs = append(errs, checkRunes("message", r.Message, p.MinMessageRunes, p.MaxMessageRunes)...)
}
return errs
}
// NormalizeAndValidate trims whitespace from all text fields and then
// checks the request against the built-in preset for the form type.
// Supported types: contact, feedback, newsletter, generic; an empty or
// unknown type falls back to contact. Callers that need their own limits
// or their own service allow-list build a Policy and call Validate.
func NormalizeAndValidate(r *Request, formType string) []FieldError {
return Validate(r, Preset(formType))
}
// checkRunes reports the length errors for one field in rune counts.
func checkRunes(field, value string, minRunes, maxRunes int) []FieldError {
n := utf8.RuneCountInString(value)
var errs []FieldError
if n < minRunes {
errs = append(errs, FieldError{
Field: field,
Message: fmt.Sprintf("%s must be at least %d characters", field, minRunes),
})
} else if n > maxRunes {
errs = append(errs, FieldError{
Field: field,
Message: fmt.Sprintf("%s must be at most %d characters", field, maxRunes),
})
}
return errs
}
// serviceAllowed reports whether s passes the allow-list. The empty value
// is always legitimate: petrbalvin.org and every other frontend is free to
// post a payload without a service field. The ServiceAny entry accepts any
// non-empty value.
func serviceAllowed(list []string, s string) bool {
if s == "" {
return true
}
if slices.Contains(list, ServiceAny) {
return true
}
return slices.Contains(list, s)
}
+372
View File
@@ -0,0 +1,372 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package contactform
import (
"slices"
"strings"
"testing"
)
func TestValidateContact_Happy(t *testing.T) {
r := &Request{
Name: "Jane Doe",
Email: "jane@example.com",
Service: "architecture",
Message: "Hello, I would like to discuss a project.",
}
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateContact_DefaultType(t *testing.T) {
// Unknown / empty type falls back to contact validation.
r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "Hello there."}
if errs := NormalizeAndValidate(r, ""); len(errs) > 0 {
t.Fatalf("expected no errors with empty type, got %v", errs)
}
}
func TestValidateContact_RejectsInvalidEmail(t *testing.T) {
r := &Request{Name: "Jane", Email: "not-an-email", Message: "A message long enough."}
errs := NormalizeAndValidate(r, "contact")
if len(errs) == 0 {
t.Fatal("expected error for invalid email")
}
if errs[0].Field != "email" {
t.Errorf("expected field=email, got %q", errs[0].Field)
}
}
func TestValidateContact_RejectsBadService(t *testing.T) {
r := &Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}
errs := NormalizeAndValidate(r, "contact")
if len(errs) == 0 || errs[0].Field != "service" {
t.Fatalf("expected service error, got %v", errs)
}
}
func TestValidateFeedback_OmitsService(t *testing.T) {
// Feedback validates like generic: name, email, message; no service field.
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}
if errs := NormalizeAndValidate(r, "feedback"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateGeneric_RejectsShortMessage(t *testing.T) {
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "short"}
errs := NormalizeAndValidate(r, "generic")
if len(errs) == 0 || errs[0].Field != "message" {
t.Fatalf("expected message error, got %v", errs)
}
}
func TestValidateNewsletter_Happy(t *testing.T) {
r := &Request{Email: "jane@example.com"}
if errs := NormalizeAndValidate(r, "newsletter"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateNewsletter_RequiresEmail(t *testing.T) {
r := &Request{Email: "garbage"}
errs := NormalizeAndValidate(r, "newsletter")
if len(errs) == 0 {
t.Fatal("expected error for missing/invalid email")
}
}
func TestValidateNewsletter_IgnoresNameAndMessage(t *testing.T) {
// Newsletter type only cares about the email field.
r := &Request{Email: "jane@example.com", Name: "", Message: ""}
if errs := NormalizeAndValidate(r, "newsletter"); len(errs) > 0 {
t.Fatalf("newsletter should ignore empty name/message, got %v", errs)
}
}
func TestValidateRejectsUnknownTypeOnlyWhenTypeExplicitlyInvalid(t *testing.T) {
// Sanity: an unsupported form type (e.g. "foo") falls back to contact
// validation, not to a hard error. The hard error is enforced at the
// config layer, not in Validate itself.
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "Hello there."}
if errs := NormalizeAndValidate(r, "foo"); len(errs) > 0 {
t.Fatalf("Validate with unknown type should fall back to contact, got %v", errs)
}
}
func TestValidateTrimsWhitespace(t *testing.T) {
r := &Request{
Name: " Jane ",
Email: " jane@example.com ",
Service: " architecture ",
Message: " hello there ",
}
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
if r.Name != "Jane" || r.Email != "jane@example.com" || r.Message != "hello there" {
t.Errorf("expected whitespace to be trimmed, got %+v", r)
}
}
// ---------------------------------------------------------------------------
// Policy-driven validation
// ---------------------------------------------------------------------------
// The presets must reproduce the exact rules the fixed validators enforced
// before the policy layer existed.
func TestPresets(t *testing.T) {
contact := Preset("contact")
if !contact.RequireName || !contact.RequireMessage {
t.Error("contact preset must require name and message")
}
if contact.MinNameRunes != MinNameRunes || contact.MaxNameRunes != MaxNameRunes {
t.Errorf("contact name limits = %d/%d, want %d/%d",
contact.MinNameRunes, contact.MaxNameRunes, MinNameRunes, MaxNameRunes)
}
if contact.MinMessageRunes != MinMessageRunes || contact.MaxMessageRunes != MaxMessageRunes {
t.Errorf("contact message limits = %d/%d, want %d/%d",
contact.MinMessageRunes, contact.MaxMessageRunes, MinMessageRunes, MaxMessageRunes)
}
if !slices.Contains(contact.Services, "architecture") || !slices.Contains(contact.Services, "other") {
t.Errorf("contact preset services = %v, want the built-in list", contact.Services)
}
for _, typ := range []string{"feedback", "generic"} {
p := Preset(typ)
if !p.RequireName || !p.RequireMessage {
t.Errorf("%s preset must require name and message", typ)
}
if p.Services != nil {
t.Errorf("%s preset must not validate service, got %v", typ, p.Services)
}
}
news := Preset("newsletter")
if news.RequireName || news.RequireMessage || news.Services != nil {
t.Errorf("newsletter preset = %+v, want email-only", news)
}
// Unknown and empty types fall back to contact.
fallback := Preset("nonsense")
if !fallback.RequireName || fallback.Services == nil {
t.Errorf("unknown type preset = %+v, want the contact preset", fallback)
}
}
// A payload without a service field is legitimate under the contact
// preset: the field is optional, never a mandatory category.
func TestValidateContactWithoutServiceField(t *testing.T) {
r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "A plain hello for you."}
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
t.Fatalf("payload without service must pass, got %v", errs)
}
}
// A nil Services list means the field is not validated at all, so any
// value rides through untouched.
func TestPolicyNilServicesSkipsValidation(t *testing.T) {
p := Preset("feedback")
r := &Request{Name: "Jane", Email: "jane@example.com", Service: "anything-goes", Message: "A message long enough."}
if errs := Validate(r, p); len(errs) > 0 {
t.Fatalf("nil services list must skip the field, got %v", errs)
}
}
func TestPolicyServices(t *testing.T) {
base := func(services []string, service string) []FieldError {
p := Policy{
RequireName: true,
RequireMessage: true,
MinNameRunes: 2, MaxNameRunes: 100,
MinMessageRunes: 10, MaxMessageRunes: 5000,
Services: services,
}
r := &Request{Name: "Jane", Email: "jane@example.com", Service: service, Message: "A message long enough."}
return Validate(r, p)
}
tests := []struct {
name string
services []string
service string
wantErr bool
}{
{"empty service always passes", []string{"consulting"}, "", false},
{"listed value passes", []string{"consulting", "support"}, "support", false},
{"unlisted value fails", []string{"consulting"}, "hairstyling", true},
{"explicit empty list rejects any value", []string{}, "consulting", true},
{"explicit empty list keeps empty value", []string{}, "", false},
{"wildcard accepts anything", []string{ServiceAny}, "hairstyling", false},
{"wildcard beside entries still accepts anything", []string{"consulting", ServiceAny}, "anything", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := base(tt.services, tt.service)
if gotErr := len(errs) > 0; gotErr != tt.wantErr {
t.Fatalf("Validate(service=%q, list=%v) errors = %v, wantErr %v",
tt.service, tt.services, errs, tt.wantErr)
}
if tt.wantErr && errs[0].Field != "service" {
t.Errorf("error field = %q, want service", errs[0].Field)
}
})
}
}
// Custom limits are honoured and the error messages carry the configured
// numbers, not the built-in defaults.
func TestPolicyCustomLimits(t *testing.T) {
p := Policy{
RequireName: true,
RequireMessage: true,
MinNameRunes: 5,
MaxNameRunes: 10,
MinMessageRunes: 20,
MaxMessageRunes: 40,
}
r := &Request{Name: "Ja", Email: "jane@example.com", Message: "too short"}
errs := Validate(r, p)
if len(errs) != 2 {
t.Fatalf("errors = %v, want two length failures", errs)
}
if errs[0].Field != "name" || !strings.Contains(errs[0].Message, "at least 5 characters") {
t.Errorf("name error = %+v, want the configured minimum", errs[0])
}
if errs[1].Field != "message" || !strings.Contains(errs[1].Message, "at least 20 characters") {
t.Errorf("message error = %+v, want the configured minimum", errs[1])
}
r2 := &Request{Name: "A very long name over the limit", Email: "jane@example.com",
Message: strings.Repeat("x", 41)}
errs = Validate(r2, p)
if len(errs) != 2 {
t.Fatalf("errors = %v, want two maximum failures", errs)
}
if !strings.Contains(errs[0].Message, "at most 10 characters") ||
!strings.Contains(errs[1].Message, "at most 40 characters") {
t.Errorf("errors = %v, want the configured maxima", errs)
}
}
// A false Require flag takes the whole field out of the checks; the field
// is still trimmed so the email body sees clean text.
func TestPolicyOptionalFields(t *testing.T) {
p := Policy{RequireName: false, RequireMessage: false, Services: []string{ServiceAny}}
r := &Request{Name: "", Email: "jane@example.com", Message: "", Service: "x"}
if errs := Validate(r, p); len(errs) > 0 {
t.Fatalf("optional fields must pass empty, got %v", errs)
}
if r.Name != "" || r.Message != "" || r.Service != "x" {
t.Errorf("fields should stay trimmed, got %+v", r)
}
}
// The email address is never optional: it is the reply-to of the mail the
// form produces.
func TestPolicyEmailAlwaysRequired(t *testing.T) {
p := Policy{}
r := &Request{Name: "Jane", Email: "not-an-email", Message: "hello"}
errs := Validate(r, p)
if len(errs) != 1 || errs[0].Field != "email" {
t.Fatalf("errors = %v, want exactly the email failure", errs)
}
}
// Validate must produce the same verdicts as the type dispatcher did
// before the policy layer: every case the old tests covered still holds
// through the wrapper.
func TestNormalizeAndValidateBackwardCompatible(t *testing.T) {
cases := []struct {
name string
formType string
req Request
wantErr bool
}{
{"contact happy", "contact", Request{Name: "Jane Doe", Email: "jane@example.com", Service: "architecture", Message: "Hello, I would like to discuss a project."}, false},
{"contact bad service", "contact", Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}, true},
{"feedback happy", "feedback", Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}, false},
{"generic short message", "generic", Request{Name: "Jane", Email: "jane@example.com", Message: "short"}, true},
{"newsletter happy", "newsletter", Request{Email: "jane@example.com"}, false},
{"newsletter bad email", "newsletter", Request{Email: "garbage"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := tc.req
errs := NormalizeAndValidate(&req, tc.formType)
if got := len(errs) > 0; got != tc.wantErr {
t.Fatalf("NormalizeAndValidate(%q) errors = %v, wantErr %v", tc.formType, errs, tc.wantErr)
}
})
}
}
func TestErrorMessageHasMinLengthPlaceholder(t *testing.T) {
// Sanity: the minLength error message in the en.json UI mirror should
// remain a simple string. Here we just check that error messages are
// human-readable, not empty.
r := &Request{Name: "J", Email: "jane@example.com", Message: "hi"}
errs := NormalizeAndValidate(r, "contact")
if len(errs) < 2 {
t.Fatalf("expected at least 2 errors, got %v", errs)
}
for _, e := range errs {
if strings.TrimSpace(e.Message) == "" {
t.Errorf("error message is empty for field %q", e.Field)
}
}
}
// FuzzValidate explores the validator with arbitrary payloads and
// policies. The invariants: it never panics, every reported error names
// one of the four known fields with a non-empty message, and validation
// is deterministic in the trimmed request.
func FuzzValidate(f *testing.F) {
seeds := []Request{
{Name: "Jane Doe", Email: "jane@example.com", Service: "architecture", Message: "Hello, I would like to discuss an engagement."},
{Name: "", Email: "", Service: "", Message: ""},
{Name: " J ", Email: " j@example.com ", Service: " unix ", Message: "x"},
{Name: "<script>", Email: "a@b", Service: "unknown", Message: strings.Repeat("m", 6000)},
{Name: "Elf", Email: "u@exämple.com", Service: "*", Message: "emoji 🚀 body"},
}
for _, s := range seeds {
f.Add(s.Name, s.Email, s.Service, s.Message, true, true, 2, 100, 10, 5000, 0)
}
f.Add("A", "a@b.c", "", "long enough", false, false, 0, 1, 0, 1, 3)
knownFields := []string{"name", "email", "service", "message"}
serviceLists := [][]string{nil, {}, {"architecture", "unix"}, {"*"}}
f.Fuzz(func(t *testing.T, name, email, service, message string,
requireName, requireMessage bool,
minName, maxName, minMessage, maxMessage, serviceChoice int) {
policy := Policy{
RequireName: requireName,
RequireMessage: requireMessage,
MinNameRunes: minName,
MaxNameRunes: maxName,
MinMessageRunes: minMessage,
MaxMessageRunes: maxMessage,
Services: serviceLists[((serviceChoice%len(serviceLists))+len(serviceLists))%len(serviceLists)],
}
req := Request{Name: name, Email: email, Service: service, Message: message}
errs := Validate(&req, policy)
for _, e := range errs {
if !slices.Contains(knownFields, e.Field) {
t.Fatalf("unknown field %q in error %q", e.Field, e.Message)
}
if strings.TrimSpace(e.Message) == "" {
t.Fatalf("empty message on field %q", e.Field)
}
}
again := Validate(&req, policy)
if !slices.Equal(errs, again) {
t.Fatalf("validation is not deterministic: %v then %v", errs, again)
}
})
}