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 }