5d92820591
Timezone is now sent from the client on each request that needs it, instead of storing it in the database. This simplifies the model and allows timezone to be dynamic (useful for traveling users). Changes: - Remove timezone field from User entity - Remove timezone from user registration - GET /habits/today now requires ?timezone= query param - Add migration to drop timezone column from database - Update related tests
128 lines
3.0 KiB
Go
128 lines
3.0 KiB
Go
package validation
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
// RFC 5322 compliant email regex (simplified but robust)
|
|
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`)
|
|
|
|
type ValidationError struct {
|
|
Field string
|
|
Message string
|
|
}
|
|
|
|
func (e ValidationError) Error() string {
|
|
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
|
}
|
|
|
|
func ValidateEmail(email string) error {
|
|
email = strings.TrimSpace(email)
|
|
|
|
if email == "" {
|
|
return ValidationError{Field: "email", Message: "email is required"}
|
|
}
|
|
|
|
if len(email) > 254 {
|
|
return ValidationError{Field: "email", Message: "email must not exceed 254 characters"}
|
|
}
|
|
|
|
if !emailRegex.MatchString(email) {
|
|
return ValidationError{Field: "email", Message: "invalid email format"}
|
|
}
|
|
|
|
parts := strings.Split(email, "@")
|
|
if len(parts[0]) > 64 {
|
|
return ValidationError{Field: "email", Message: "email local part must not exceed 64 characters"}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ValidatePassword(password string) error {
|
|
if password == "" {
|
|
return ValidationError{Field: "password", Message: "password is required"}
|
|
}
|
|
|
|
if len(password) < 8 {
|
|
return ValidationError{Field: "password", Message: "password must be at least 8 characters long"}
|
|
}
|
|
|
|
if len(password) > 128 {
|
|
return ValidationError{Field: "password", Message: "password must not exceed 128 characters"}
|
|
}
|
|
|
|
var (
|
|
hasUpper bool
|
|
hasLower bool
|
|
hasDigit bool
|
|
hasSpecial bool
|
|
)
|
|
|
|
for _, char := range password {
|
|
switch {
|
|
case unicode.IsUpper(char):
|
|
hasUpper = true
|
|
case unicode.IsLower(char):
|
|
hasLower = true
|
|
case unicode.IsDigit(char):
|
|
hasDigit = true
|
|
case unicode.IsPunct(char) || unicode.IsSymbol(char):
|
|
hasSpecial = true
|
|
}
|
|
}
|
|
|
|
if !hasUpper {
|
|
return ValidationError{Field: "password", Message: "password must contain at least one uppercase letter"}
|
|
}
|
|
|
|
if !hasLower {
|
|
return ValidationError{Field: "password", Message: "password must contain at least one lowercase letter"}
|
|
}
|
|
|
|
if !hasDigit {
|
|
return ValidationError{Field: "password", Message: "password must contain at least one digit"}
|
|
}
|
|
|
|
if !hasSpecial {
|
|
return ValidationError{Field: "password", Message: "password must contain at least one special character"}
|
|
}
|
|
|
|
if IsCommonPassword(password) {
|
|
return ValidationError{Field: "password", Message: "password is too common, please choose a more secure password"}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ValidateTimezone(timezone string) error {
|
|
timezone = strings.TrimSpace(timezone)
|
|
|
|
if timezone == "" {
|
|
return ValidationError{Field: "timezone", Message: "timezone is required"}
|
|
}
|
|
|
|
_, err := time.LoadLocation(timezone)
|
|
if err != nil {
|
|
return ValidationError{Field: "timezone", Message: "invalid timezone, must be a valid IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')"}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ValidateRegistration(email, password string) error {
|
|
if err := ValidateEmail(email); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := ValidatePassword(password); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|