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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user