You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

185 lines
4.8 KiB
Go

// Package validation provides validation utilities for the KnowFoolery application.
package validation
import (
"fmt"
"reflect"
"strings"
"unicode"
"github.com/go-playground/validator/v10"
"knowfoolery/backend/shared/domain/errors"
)
// Validator wraps the go-playground validator with custom validations.
type Validator struct {
validate *validator.Validate
}
// NewValidator creates a new Validator with custom validations registered.
func NewValidator() *Validator {
v := validator.New()
// Use JSON tag names in error messages
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
// Register custom validations
err := v.RegisterValidation("alphanum_space", validateAlphanumSpace)
if err != nil {
panic(err)
}
err = v.RegisterValidation("no_html", validateNoHTML)
if err != nil {
panic(err)
}
err = v.RegisterValidation("safe_text", validateSafeText)
if err != nil {
panic(err)
}
err = v.RegisterValidation("player_name", validatePlayerName)
if err != nil {
panic(err)
}
return &Validator{validate: v}
}
// Validate validates a struct and returns a domain error if validation fails.
func (v *Validator) Validate(s interface{}) error {
err := v.validate.Struct(s)
if err == nil {
return nil
}
validationErrors, ok := err.(validator.ValidationErrors)
if !ok {
return errors.Wrap(errors.CodeValidationFailed, "validation failed", err)
}
messages := make([]string, 0, len(validationErrors))
for _, e := range validationErrors {
messages = append(messages, formatValidationError(e))
}
return errors.Wrap(
errors.CodeValidationFailed,
strings.Join(messages, "; "),
nil,
)
}
// ValidateVar validates a single variable.
func (v *Validator) ValidateVar(field interface{}, tag string) error {
err := v.validate.Var(field, tag)
if err == nil {
return nil
}
return errors.Wrap(errors.CodeValidationFailed, "validation failed", err)
}
// formatValidationError formats a single validation error into a readable message.
func formatValidationError(e validator.FieldError) string {
field := e.Field()
switch e.Tag() {
case "required":
return fmt.Sprintf("%s is required", field)
case "min":
return fmt.Sprintf("%s must be at least %s characters", field, e.Param())
case "max":
return fmt.Sprintf("%s must be at most %s characters", field, e.Param())
case "email":
return fmt.Sprintf("%s must be a valid email", field)
case "alphanum":
return fmt.Sprintf("%s must contain only alphanumeric characters", field)
case "alphanum_space":
return fmt.Sprintf("%s must contain only alphanumeric characters and spaces", field)
case "player_name":
return fmt.Sprintf("%s must be a valid player name (2-50 chars, alphanumeric with spaces)", field)
case "oneof":
return fmt.Sprintf("%s must be one of: %s", field, e.Param())
case "gte":
return fmt.Sprintf("%s must be greater than or equal to %s", field, e.Param())
case "lte":
return fmt.Sprintf("%s must be less than or equal to %s", field, e.Param())
case "uuid":
return fmt.Sprintf("%s must be a valid UUID", field)
default:
return fmt.Sprintf("%s is invalid", field)
}
}
// Custom validation functions
// validateAlphanumSpace validates that a string contains only alphanumeric characters and spaces.
func validateAlphanumSpace(fl validator.FieldLevel) bool {
str := fl.Field().String()
for _, r := range str {
if !unicode.IsLetter(r) && !unicode.IsNumber(r) && !unicode.IsSpace(r) && r != '-' && r != '_' && r != '.' {
return false
}
}
return true
}
// validateNoHTML validates that a string contains no HTML tags.
func validateNoHTML(fl validator.FieldLevel) bool {
str := fl.Field().String()
return !strings.Contains(str, "<") && !strings.Contains(str, ">")
}
// validateSafeText validates that a string contains no potentially dangerous patterns.
func validateSafeText(fl validator.FieldLevel) bool {
str := strings.ToLower(fl.Field().String())
dangerousPatterns := []string{
"javascript:",
"data:",
"vbscript:",
"<script",
"</script",
}
for _, pattern := range dangerousPatterns {
if strings.Contains(str, pattern) {
return false
}
}
return true
}
// validatePlayerName validates a player name format.
func validatePlayerName(fl validator.FieldLevel) bool {
str := fl.Field().String()
// Length check
if len(str) < 2 || len(str) > 50 {
return false
}
// Character validation
for _, r := range str {
if !unicode.IsLetter(r) && !unicode.IsNumber(r) && !unicode.IsSpace(r) && r != '-' && r != '_' && r != '.' {
return false
}
}
return true
}
// Common validation tags for reuse
const (
TagRequired = "required"
TagPlayerName = "required,player_name"
TagEmail = "required,email"
TagUUID = "required,uuid"
TagOptionalUUID = "omitempty,uuid"
)