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
173 lines
5.5 KiB
Go
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)
|
|
}
|