Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
498
cloud/maplepress-backend/pkg/validation/validator.go
Normal file
498
cloud/maplepress-backend/pkg/validation/validator.go
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue