Files
nuntius/internal/contactform/validate.go
T
petrbalvin 3a38f00dc0
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
feat: contact form backend for linux and freebsd servers
Assisted-by: GLM 5.3 Flash
2026-09-29 00:32:56 +02:00

173 lines
5.5 KiB
Go

// 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)
}