Files
cv-site/internal/validation/rules.go
T
juanatsap 6c7595b041 feat: add tag-based validation system with reflection caching
Implement a declarative struct tag validation system for Go:

- Add validator.go with sync.Map caching for reflection metadata
- Add rules.go with 11 built-in validation rules (required, email,
  pattern, honeypot, timing, etc.)
- Add errors.go with FieldError and ValidationErrors types
- Update ContactFormRequest with validate tags
- Add ValidateContactFormV2() using the new tag-based validator

Rules implemented:
- required/optional: field presence validation
- trim/sanitize: automatic value transformations
- min/max: UTF-8 aware length validation
- email: RFC 5322 email format validation
- pattern: predefined regex patterns (name, subject, company)
- no_injection: email header injection prevention
- honeypot: bot trap (must be empty)
- timing: timestamp validation for bot detection

Documentation:
- docs/go-validation-system.md: complete validation guide
- docs/go-template-system.md: template manager documentation
- docs/go-routes-api.md: routes and API reference
- docs/README.md: documentation index
2025-12-06 15:20:45 +00:00

281 lines
6.9 KiB
Go

package validation
import (
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
)
// ValidationRule represents a validation rule function
type ValidationRule func(field string, value string, param string) *FieldError
// Built-in validation rules registry
var validationRules = map[string]ValidationRule{
"required": ruleRequired,
"optional": ruleOptional,
"trim": ruleTrim,
"min": ruleMin,
"max": ruleMax,
"email": ruleEmail,
"pattern": rulePattern,
"no_injection": ruleNoInjection,
"sanitize": ruleSanitize,
"honeypot": ruleHoneypot,
"timing": ruleTiming,
}
// Pre-compiled regex patterns for performance
var (
namePattern *regexp.Regexp
subjectPattern *regexp.Regexp
companyPattern *regexp.Regexp
)
func init() {
// Pre-compile patterns at startup for maximum performance
namePattern = regexp.MustCompile(`^[\p{L}\s'-]+$`)
subjectPattern = regexp.MustCompile(`^[\p{L}\p{N}\s.,!?'"()\-:;#]+$`)
companyPattern = regexp.MustCompile(`^[\p{L}\p{N}\s.,&'()\-]+$`)
}
// ruleRequired validates that the field is not empty (after trimming)
func ruleRequired(field string, value string, param string) *FieldError {
if strings.TrimSpace(value) == "" {
return &FieldError{
Field: field,
Tag: "required",
Message: field + " is required",
}
}
return nil
}
// ruleOptional is a marker rule - it always passes
// This allows explicit marking of optional fields in tags
func ruleOptional(field string, value string, param string) *FieldError {
return nil
}
// ruleTrim validates and modifies the value (trim whitespace)
// Note: This is a special rule that modifies the value in-place
func ruleTrim(field string, value string, param string) *FieldError {
// This rule doesn't validate, it's a marker for the validator to trim
return nil
}
// ruleMin validates minimum rune length (UTF-8 aware)
func ruleMin(field string, value string, param string) *FieldError {
minLen, err := strconv.Atoi(param)
if err != nil {
return &FieldError{
Field: field,
Tag: "min",
Param: param,
Message: "invalid min parameter: " + param,
}
}
runeCount := utf8.RuneCountInString(value)
if runeCount < minLen {
return &FieldError{
Field: field,
Tag: "min",
Param: param,
Message: field + " must be at least " + param + " characters",
}
}
return nil
}
// ruleMax validates maximum rune length (UTF-8 aware)
func ruleMax(field string, value string, param string) *FieldError {
maxLen, err := strconv.Atoi(param)
if err != nil {
return &FieldError{
Field: field,
Tag: "max",
Param: param,
Message: "invalid max parameter: " + param,
}
}
runeCount := utf8.RuneCountInString(value)
if runeCount > maxLen {
return &FieldError{
Field: field,
Tag: "max",
Param: param,
Message: field + " must be " + param + " characters or less",
}
}
return nil
}
// ruleEmail validates email format using the existing IsValidEmail function
func ruleEmail(field string, value string, param string) *FieldError {
if value == "" {
return nil // Skip validation for empty values (use 'required' if needed)
}
if !IsValidEmail(value) {
return &FieldError{
Field: field,
Tag: "email",
Message: "Invalid email address format",
}
}
return nil
}
// rulePattern validates against predefined patterns
// Supported patterns: name, subject, company
func rulePattern(field string, value string, param string) *FieldError {
if value == "" {
return nil // Skip validation for empty values
}
var valid bool
var patternName string
switch param {
case "name":
valid = namePattern.MatchString(strings.TrimSpace(value))
patternName = "name (letters, spaces, hyphens, apostrophes only)"
case "subject":
valid = subjectPattern.MatchString(strings.TrimSpace(value))
patternName = "subject (alphanumeric and safe punctuation only)"
case "company":
valid = companyPattern.MatchString(strings.TrimSpace(value))
patternName = "company (alphanumeric and business punctuation only)"
default:
return &FieldError{
Field: field,
Tag: "pattern",
Param: param,
Message: "unknown pattern: " + param,
}
}
if !valid {
return &FieldError{
Field: field,
Tag: "pattern",
Param: param,
Message: field + " contains invalid characters for " + patternName,
}
}
return nil
}
// ruleNoInjection validates that the value doesn't contain email header injection attempts
func ruleNoInjection(field string, value string, param string) *FieldError {
if value == "" {
return nil // Skip validation for empty values
}
if ContainsEmailInjection(value) {
return &FieldError{
Field: field,
Tag: "no_injection",
Message: field + " contains invalid characters (possible injection attempt)",
}
}
return nil
}
// ruleSanitize is a marker rule for HTML sanitization
// The actual sanitization happens in the validator
func ruleSanitize(field string, value string, param string) *FieldError {
// This rule doesn't validate, it's a marker for the validator to sanitize
return nil
}
// ruleHoneypot validates that the honeypot field is empty (bot detection)
func ruleHoneypot(field string, value string, param string) *FieldError {
if value != "" {
return &FieldError{
Field: field,
Tag: "honeypot",
Message: "Bot detected",
}
}
return nil
}
// ruleTiming validates form submission timing (anti-bot measure)
// Param format: "min:max" in seconds (e.g., "2:86400" = 2 seconds to 24 hours)
func ruleTiming(field string, value string, param string) *FieldError {
if value == "" {
return nil // Skip if no timestamp provided
}
// Parse the timing parameter "min:max"
parts := strings.Split(param, ":")
if len(parts) != 2 {
return &FieldError{
Field: field,
Tag: "timing",
Param: param,
Message: "invalid timing parameter format (expected min:max)",
}
}
minSeconds, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return &FieldError{
Field: field,
Tag: "timing",
Param: param,
Message: "invalid minimum timing value",
}
}
maxSeconds, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return &FieldError{
Field: field,
Tag: "timing",
Param: param,
Message: "invalid maximum timing value",
}
}
// Parse the timestamp value
timestamp, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return &FieldError{
Field: field,
Tag: "timing",
Message: "invalid timestamp format",
}
}
// Calculate time difference
now := time.Now().Unix()
timeTaken := now - timestamp
// Check if submitted too quickly
if timeTaken < minSeconds {
return &FieldError{
Field: field,
Tag: "timing",
Param: param,
Message: "Form submitted too quickly (bot detected)",
}
}
// Check if timestamp is invalid (future or too old)
if timeTaken < 0 || timeTaken > maxSeconds {
return &FieldError{
Field: field,
Tag: "timing",
Param: param,
Message: "Invalid timestamp",
}
}
return nil
}