Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View 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
}