package validation import ( "fmt" "net/mail" "net/url" "regexp" "strings" "time" "unicode" ) // Common validation errors var ( ErrRequired = fmt.Errorf("field is required") ErrInvalidEmail = fmt.Errorf("invalid email format") ErrInvalidURL = fmt.Errorf("invalid URL format") ErrInvalidDomain = fmt.Errorf("invalid domain format") ErrTooShort = fmt.Errorf("value is too short") ErrTooLong = fmt.Errorf("value is too long") ErrInvalidCharacters = fmt.Errorf("contains invalid characters") ErrInvalidFormat = fmt.Errorf("invalid format") ErrInvalidValue = fmt.Errorf("invalid value") ErrWhitespaceOnly = fmt.Errorf("cannot contain only whitespace") ErrContainsHTML = fmt.Errorf("cannot contain HTML tags") ErrInvalidSlug = fmt.Errorf("invalid slug format") ) // Regex patterns for validation var ( // Email validation: RFC 5322 compliant emailRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._%+\-]*[a-zA-Z0-9]@[a-zA-Z0-9][a-zA-Z0-9.\-]*[a-zA-Z0-9]\.[a-zA-Z]{2,}$`) // Domain validation: alphanumeric with dots and hyphens domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`) // Slug validation: lowercase alphanumeric with hyphens slugRegex = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) // HTML tag detection htmlTagRegex = regexp.MustCompile(`<[^>]+>`) // UUID validation (version 4) uuidRegex = regexp.MustCompile(`^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$`) // Alphanumeric only alphanumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]+$`) ) // Reserved slugs that cannot be used for tenant names var ReservedSlugs = map[string]bool{ "api": true, "admin": true, "www": true, "mail": true, "email": true, "health": true, "status": true, "metrics": true, "static": true, "cdn": true, "assets": true, "blog": true, "docs": true, "help": true, "support": true, "login": true, "logout": true, "signup": true, "register": true, "app": true, "dashboard": true, "settings": true, "account": true, "profile": true, "root": true, "system": true, "public": true, "private": true, } // Validator provides input validation utilities type Validator struct{} // NewValidator creates a new validator instance func NewValidator() *Validator { return &Validator{} } // ==================== String Validation ==================== // ValidateRequired checks if a string is not empty func (v *Validator) ValidateRequired(value, fieldName string) error { if strings.TrimSpace(value) == "" { return fmt.Errorf("%s: %w", fieldName, ErrRequired) } return nil } // ValidateLength checks if string length is within range func (v *Validator) ValidateLength(value, fieldName string, min, max int) error { length := len(strings.TrimSpace(value)) if length < min { return fmt.Errorf("%s: %w (minimum %d characters)", fieldName, ErrTooShort, min) } if max > 0 && length > max { return fmt.Errorf("%s: %w (maximum %d characters)", fieldName, ErrTooLong, max) } return nil } // ValidateNotWhitespaceOnly ensures the string contains non-whitespace characters func (v *Validator) ValidateNotWhitespaceOnly(value, fieldName string) error { if len(strings.TrimSpace(value)) == 0 && len(value) > 0 { return fmt.Errorf("%s: %w", fieldName, ErrWhitespaceOnly) } return nil } // ValidateNoHTML checks that the string doesn't contain HTML tags func (v *Validator) ValidateNoHTML(value, fieldName string) error { if htmlTagRegex.MatchString(value) { return fmt.Errorf("%s: %w", fieldName, ErrContainsHTML) } return nil } // ValidateAlphanumeric checks if string contains only alphanumeric characters func (v *Validator) ValidateAlphanumeric(value, fieldName string) error { if !alphanumericRegex.MatchString(value) { return fmt.Errorf("%s: %w (only letters and numbers allowed)", fieldName, ErrInvalidCharacters) } return nil } // ValidatePrintable ensures string contains only printable characters func (v *Validator) ValidatePrintable(value, fieldName string) error { for _, r := range value { if !unicode.IsPrint(r) && !unicode.IsSpace(r) { return fmt.Errorf("%s: %w (contains non-printable characters)", fieldName, ErrInvalidCharacters) } } return nil } // ==================== Email Validation ==================== // ValidateEmail validates email format using RFC 5322 compliant regex func (v *Validator) ValidateEmail(email, fieldName string) error { email = strings.TrimSpace(email) // Check required if email == "" { return fmt.Errorf("%s: %w", fieldName, ErrRequired) } // Check length (RFC 5321: max 320 chars) if len(email) > 320 { return fmt.Errorf("%s: %w (maximum 320 characters)", fieldName, ErrTooLong) } // Validate using regex if !emailRegex.MatchString(email) { return fmt.Errorf("%s: %w", fieldName, ErrInvalidEmail) } // Additional validation using net/mail package _, err := mail.ParseAddress(email) if err != nil { return fmt.Errorf("%s: %w", fieldName, ErrInvalidEmail) } // Check for consecutive dots if strings.Contains(email, "..") { return fmt.Errorf("%s: %w (consecutive dots not allowed)", fieldName, ErrInvalidEmail) } // Check for leading/trailing dots in local part parts := strings.Split(email, "@") if len(parts) == 2 { if strings.HasPrefix(parts[0], ".") || strings.HasSuffix(parts[0], ".") { return fmt.Errorf("%s: %w (local part cannot start or end with dot)", fieldName, ErrInvalidEmail) } } return nil } // ==================== URL Validation ==================== // ValidateURL validates URL format and ensures it has a valid scheme func (v *Validator) ValidateURL(urlStr, fieldName string) error { urlStr = strings.TrimSpace(urlStr) // Check required if urlStr == "" { return fmt.Errorf("%s: %w", fieldName, ErrRequired) } // Check length (max 2048 chars for URL) if len(urlStr) > 2048 { return fmt.Errorf("%s: %w (maximum 2048 characters)", fieldName, ErrTooLong) } // Parse URL parsedURL, err := url.Parse(urlStr) if err != nil { return fmt.Errorf("%s: %w", fieldName, ErrInvalidURL) } // Ensure scheme is present and valid if parsedURL.Scheme == "" { return fmt.Errorf("%s: %w (missing scheme)", fieldName, ErrInvalidURL) } // Only allow http and https if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { return fmt.Errorf("%s: %w (only http and https schemes allowed)", fieldName, ErrInvalidURL) } // Ensure host is present if parsedURL.Host == "" { return fmt.Errorf("%s: %w (missing host)", fieldName, ErrInvalidURL) } return nil } // ValidateHTTPSURL validates URL and ensures it uses HTTPS func (v *Validator) ValidateHTTPSURL(urlStr, fieldName string) error { if err := v.ValidateURL(urlStr, fieldName); err != nil { return err } parsedURL, err := url.Parse(urlStr) if err != nil { return fmt.Errorf("%s: invalid URL format", fieldName) } if parsedURL.Scheme != "https" { return fmt.Errorf("%s: must use HTTPS protocol", fieldName) } return nil } // ==================== Domain Validation ==================== // ValidateDomain validates domain name format // Supports standard domains (example.com) and localhost with ports (localhost:8081) for development func (v *Validator) ValidateDomain(domain, fieldName string) error { domain = strings.TrimSpace(strings.ToLower(domain)) // Check required if domain == "" { return fmt.Errorf("%s: %w", fieldName, ErrRequired) } // Check length (max 253 chars per RFC 1035) if len(domain) > 253 { return fmt.Errorf("%s: %w (maximum 253 characters)", fieldName, ErrTooLong) } // Check minimum length if len(domain) < 4 { return fmt.Errorf("%s: %w (minimum 4 characters)", fieldName, ErrTooShort) } // Allow localhost with optional port for development // Examples: localhost, localhost:8080, localhost:3000 if strings.HasPrefix(domain, "localhost") { // If it has a port, validate the port format if strings.Contains(domain, ":") { parts := strings.Split(domain, ":") if len(parts) != 2 { return fmt.Errorf("%s: %w (invalid localhost format)", fieldName, ErrInvalidDomain) } // Port should be numeric if parts[1] == "" { return fmt.Errorf("%s: %w (missing port number)", fieldName, ErrInvalidDomain) } // Basic port validation (could be more strict) for _, c := range parts[1] { if c < '0' || c > '9' { return fmt.Errorf("%s: %w (port must be numeric)", fieldName, ErrInvalidDomain) } } } return nil } // Allow 127.0.0.1 and other local IPs with optional port for development if strings.HasPrefix(domain, "127.") || strings.HasPrefix(domain, "192.168.") || strings.HasPrefix(domain, "10.") { // If it has a port, just verify format (IP:port) if strings.Contains(domain, ":") { parts := strings.Split(domain, ":") if len(parts) != 2 { return fmt.Errorf("%s: %w (invalid IP format)", fieldName, ErrInvalidDomain) } } return nil } // Validate standard domain format (example.com) if !domainRegex.MatchString(domain) { return fmt.Errorf("%s: %w", fieldName, ErrInvalidDomain) } // Check each label length (max 63 chars per RFC 1035) labels := strings.Split(domain, ".") for _, label := range labels { if len(label) > 63 { return fmt.Errorf("%s: %w (label exceeds 63 characters)", fieldName, ErrInvalidDomain) } } return nil } // ==================== Slug Validation ==================== // ValidateSlug validates slug format (lowercase alphanumeric with hyphens) func (v *Validator) ValidateSlug(slug, fieldName string) error { slug = strings.TrimSpace(strings.ToLower(slug)) // Check required if slug == "" { return fmt.Errorf("%s: %w", fieldName, ErrRequired) } // Check length (3-63 chars) if len(slug) < 3 { return fmt.Errorf("%s: %w (minimum 3 characters)", fieldName, ErrTooShort) } if len(slug) > 63 { return fmt.Errorf("%s: %w (maximum 63 characters)", fieldName, ErrTooLong) } // Validate format if !slugRegex.MatchString(slug) { return fmt.Errorf("%s: %w (only lowercase letters, numbers, and hyphens allowed)", fieldName, ErrInvalidSlug) } // Check for reserved slugs if ReservedSlugs[slug] { return fmt.Errorf("%s: '%s' is a reserved slug and cannot be used", fieldName, slug) } return nil } // GenerateSlug generates a URL-friendly slug from a name // Converts to lowercase, replaces spaces and special chars with hyphens // Ensures the slug matches the slug validation regex func (v *Validator) GenerateSlug(name string) string { // Convert to lowercase and trim spaces slug := strings.TrimSpace(strings.ToLower(name)) // Replace any non-alphanumeric characters (except hyphens) with hyphens var result strings.Builder prevWasHyphen := false for _, char := range slug { if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') { result.WriteRune(char) prevWasHyphen = false } else if !prevWasHyphen { // Replace any non-alphanumeric character with a hyphen // But don't add consecutive hyphens result.WriteRune('-') prevWasHyphen = true } } slug = result.String() // Remove leading and trailing hyphens slug = strings.Trim(slug, "-") // Enforce length constraints (3-63 chars) if len(slug) < 3 { // If too short, pad with random suffix slug = slug + "-" + strings.ToLower(fmt.Sprintf("%d", time.Now().UnixNano()%10000)) } if len(slug) > 63 { // Truncate to 63 chars slug = slug[:63] // Remove trailing hyphen if any slug = strings.TrimRight(slug, "-") } return slug } // ==================== UUID Validation ==================== // ValidateUUID validates UUID format (version 4) func (v *Validator) ValidateUUID(id, fieldName string) error { id = strings.TrimSpace(strings.ToLower(id)) // Check required if id == "" { return fmt.Errorf("%s: %w", fieldName, ErrRequired) } // Validate format if !uuidRegex.MatchString(id) { return fmt.Errorf("%s: %w (must be a valid UUID v4)", fieldName, ErrInvalidFormat) } return nil } // ==================== Enum Validation ==================== // ValidateEnum checks if value is in the allowed list (whitelist validation) func (v *Validator) ValidateEnum(value, fieldName string, allowedValues []string) error { value = strings.TrimSpace(value) // Check required if value == "" { return fmt.Errorf("%s: %w", fieldName, ErrRequired) } // Check if value is in allowed list for _, allowed := range allowedValues { if value == allowed { return nil } } return fmt.Errorf("%s: %w (allowed values: %s)", fieldName, ErrInvalidValue, strings.Join(allowedValues, ", ")) } // ==================== Number Validation ==================== // ValidateRange checks if a number is within the specified range func (v *Validator) ValidateRange(value int, fieldName string, min, max int) error { if value < min { return fmt.Errorf("%s: value must be at least %d", fieldName, min) } if max > 0 && value > max { return fmt.Errorf("%s: value must be at most %d", fieldName, max) } return nil } // ==================== Sanitization ==================== // SanitizeString removes potentially dangerous characters and trims whitespace func (v *Validator) SanitizeString(value string) string { // Trim whitespace value = strings.TrimSpace(value) // Remove null bytes value = strings.ReplaceAll(value, "\x00", "") // Normalize Unicode // Note: For production, consider using golang.org/x/text/unicode/norm return value } // StripHTML removes all HTML tags from a string func (v *Validator) StripHTML(value string) string { return htmlTagRegex.ReplaceAllString(value, "") } // ==================== Combined Validations ==================== // ValidateAndSanitizeString performs validation and sanitization func (v *Validator) ValidateAndSanitizeString(value, fieldName string, minLen, maxLen int) (string, error) { // Sanitize first value = v.SanitizeString(value) // Validate required if err := v.ValidateRequired(value, fieldName); err != nil { return "", err } // Validate length if err := v.ValidateLength(value, fieldName, minLen, maxLen); err != nil { return "", err } // Validate printable characters if err := v.ValidatePrintable(value, fieldName); err != nil { return "", err } return value, nil }