498 lines
14 KiB
Go
498 lines
14 KiB
Go
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
|
|
}
|