Files
summercms/modules/lagoon/validate_rules.go
Jakub Zych 3ac1d64ab4 fix(12-05): cast floats to strings with PHP's 14-digit precision in request validation
PHP 8's (string) cast of a float formats with the precision ini (14
significant digits, %.14G), not the shortest round-trip form: 1/3 is
0.33333333333333, 1e14 is 1.0E+14 and 5e-324 is 4.9406564584125E-324.
phpFloatString, used for the string form of JSON floats in size, in, regex
and integer checks, printed up to 17 digits and switched to the exponent
form only from 1e15. TestPHPFloatStringMatchesPHPCast pins 27 values to
php -r output.
2026-10-02 15:46:24 +02:00

1236 lines
36 KiB
Go

package lagoon
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"math"
"math/big"
"net"
"net/http"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
)
// implicitRuleNames run even when the attribute is absent or blank, and a
// failure of one stops the attribute (Laravel's implicitRules).
var implicitRuleNames = []string{"required", "present", "filled", "accepted"}
// numericRuleNames make the size rules compare the value as a number.
var numericRuleNames = []string{"numeric", "integer"}
var sizeRuleNames = map[string]bool{"size": true, "between": true, "min": true, "max": true}
// imageExtensions is Laravel's image rule: mimes:jpg,jpeg,png,gif,bmp,svg,webp.
var imageExtensions = []string{"jpg", "jpeg", "png", "gif", "bmp", "svg", "webp"}
// requestRuleArity lists every rule ValidateRequest implements with its
// parameter count: -1 any number (at least one), 0 none.
var requestRuleArity = map[string]int{
"required": 0, "present": 0, "filled": 0, "accepted": 0,
"nullable": 0, "sometimes": 0, "bail": 0,
"array": -2, "string": 0, "integer": 0, "numeric": 0, "boolean": 0,
"email": 0, "url": 0, "date": 0,
"after": 1, "after_or_equal": 1, "before": 1, "before_or_equal": 1,
"exists": -1, "regex": 1, "not_regex": 1,
"in": -1, "not_in": -1, "mimes": -1, "image": 0, "file": 0,
"min": 1, "max": 1, "size": 1, "between": 2,
}
func isImplicitName(name string) bool {
for _, n := range implicitRuleNames {
if n == name {
return true
}
}
return false
}
func (r Rule) isImplicit() bool {
return r.custom == nil && isImplicitName(r.name)
}
type compiledRegex struct {
re *regexp.Regexp
}
// ParseRules parses a Laravel rule string such as "required|string|max:255"
// into rules. Parameters are split like PHP's str_getcsv (`in:LP,"EP 7"""`);
// a regex: or not_regex: parameter is kept whole, pipes and commas included,
// up to its closing PCRE delimiter. ParseRules is meant for rule tables built
// at package initialization: an unknown rule, a wrong parameter count, an
// unsafe exists: identifier or a pattern Go's RE2 cannot compile panics, so a
// broken table fails at boot and never at request time.
func ParseRules(spec string) []Rule {
var out []Rule
for _, tok := range splitRuleSpec(spec) {
out = append(out, mustParseRule(tok))
}
return out
}
func splitRuleSpec(spec string) []string {
var out []string
rest := spec
for rest != "" {
trimmed := strings.TrimLeft(rest, " \t")
if strings.HasPrefix(trimmed, "regex:") || strings.HasPrefix(trimmed, "not_regex:") {
name, pat, _ := strings.Cut(trimmed, ":")
end := regexTokenEnd(pat)
out = append(out, name+":"+pat[:end])
rest = pat[end:]
rest = strings.TrimPrefix(rest, "|")
continue
}
tok, after, found := strings.Cut(rest, "|")
if t := strings.TrimSpace(tok); t != "" {
out = append(out, t)
}
if !found {
break
}
rest = after
}
return out
}
// regexTokenEnd finds where a PCRE literal ends inside a rule string: at the
// first unescaped closing delimiter that is followed by modifiers and then a
// pipe or the end of the string.
func regexTokenEnd(pat string) int {
if pat == "" {
return 0
}
open, size := utf8.DecodeRuneInString(pat)
closing := closingDelimiter(open)
for i := size; i < len(pat); i++ {
c := pat[i]
if c == '\\' {
i++
continue
}
if rune(c) != closing {
continue
}
j := i + 1
for j < len(pat) && isPCREModifier(pat[j]) {
j++
}
if j == len(pat) || pat[j] == '|' {
return j
}
}
if k := strings.Index(pat, "|"); k >= 0 {
return k
}
return len(pat)
}
func closingDelimiter(open rune) rune {
switch open {
case '(':
return ')'
case '[':
return ']'
case '{':
return '}'
case '<':
return '>'
}
return open
}
func isPCREModifier(c byte) bool {
return strings.IndexByte("imsxuADSUXJn", c) >= 0
}
func mustParseRule(tok string) Rule {
name, param, hasParam := strings.Cut(tok, ":")
name = strings.ToLower(strings.TrimSpace(name))
switch name {
case "int":
name = "integer"
case "bool":
name = "boolean"
}
arity, known := requestRuleArity[name]
if !known {
panic(fmt.Sprintf("lagoon: ParseRules: unsupported rule %q", tok))
}
var args []string
if hasParam {
if name == "regex" || name == "not_regex" {
args = []string{param}
} else {
args = phpGetCSV(param)
}
}
switch {
case arity == 0 && len(args) > 0:
panic(fmt.Sprintf("lagoon: ParseRules: rule %q takes no parameters", tok))
case arity > 0 && len(args) != arity:
panic(fmt.Sprintf("lagoon: ParseRules: rule %q needs %d parameter(s)", tok, arity))
case arity == -1 && len(args) == 0:
panic(fmt.Sprintf("lagoon: ParseRules: rule %q needs parameters", tok))
}
r := Rule{name: name, args: args}
switch name {
case "min", "max", "size", "between":
for _, a := range args {
if _, ok := new(big.Rat).SetString(strings.TrimSpace(a)); !ok {
panic(fmt.Sprintf("lagoon: ParseRules: rule %q has a non-numeric parameter", tok))
}
}
case "regex", "not_regex":
re, err := compilePCRE(args[0])
if err != nil {
panic(fmt.Sprintf("lagoon: ParseRules: rule %q: %v", tok, err))
}
r.re = &compiledRegex{re: re}
case "exists":
if len(args) > 2 {
panic(fmt.Sprintf("lagoon: ParseRules: rule %q: extra where clauses are not supported", tok))
}
table := args[0]
if i := strings.LastIndex(table, "."); i >= 0 {
table = table[i+1:]
}
if !identName.MatchString(table) {
panic(fmt.Sprintf("lagoon: ParseRules: rule %q: unsafe table name", tok))
}
r.args[0] = table
if len(args) == 2 && args[1] != "NULL" && !identName.MatchString(args[1]) {
panic(fmt.Sprintf("lagoon: ParseRules: rule %q: unsafe column name", tok))
}
}
return r
}
// phpGetCSV splits a rule parameter list like PHP's str_getcsv: commas
// separate fields, a field may be double-quoted with "" as an escaped quote,
// and a quote inside an unquoted field is kept.
func phpGetCSV(s string) []string {
r := csv.NewReader(strings.NewReader(s))
r.LazyQuotes = true
r.FieldsPerRecord = -1
rec, err := r.Read()
if err != nil {
return []string{s}
}
return rec
}
// compilePCRE turns a PCRE literal (/pattern/flags) into a Go regexp. The i,
// m, s, u and D modifiers are supported (u is implied, D is Go's default end
// anchoring); any other modifier is an error.
func compilePCRE(lit string) (*regexp.Regexp, error) {
if len(lit) < 2 {
return nil, fmt.Errorf("pattern %q has no delimiters", lit)
}
open, size := utf8.DecodeRuneInString(lit)
if open == '\\' || open == utf8.RuneError || (open < 128 && (isAlnumByte(byte(open)) || open == ' ')) {
return nil, fmt.Errorf("pattern %q has an invalid delimiter", lit)
}
closing := closingDelimiter(open)
end := strings.LastIndex(lit, string(closing))
if end < size {
return nil, fmt.Errorf("pattern %q has no closing delimiter", lit)
}
body := lit[size:end]
flags := ""
for _, m := range lit[end+1:] {
switch m {
case 'i', 'm', 's':
if !strings.ContainsRune(flags, m) {
flags += string(m)
}
case 'u', 'D':
default:
return nil, fmt.Errorf("pattern %q uses the unsupported modifier %q", lit, m)
}
}
if open == closing {
body = strings.ReplaceAll(body, `\`+string(open), string(open))
}
if flags != "" {
body = "(?" + flags + ")" + body
}
return regexp.Compile(body)
}
func isAlnumByte(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}
func (v *requestValidator) passes(rule Rule, attr string, value any, present bool) (bool, error) {
switch rule.name {
case "required":
return validateRequired(value), nil
case "present":
return present, nil
case "filled":
return !present || validateRequired(value), nil
case "accepted":
return validateRequired(value) && isAccepted(value), nil
case "nullable", "sometimes", "bail":
return true, nil
case "array":
return validateArray(value, rule.args), nil
case "string":
_, ok := value.(string)
return ok, nil
case "integer":
return filterInt(value), nil
case "numeric":
return isNumeric(value), nil
case "boolean":
return isStrictBoolean(value), nil
case "email":
s, ok := value.(string)
return ok && filterEmail(s), nil
case "url":
s, ok := value.(string)
return ok && urlPattern.MatchString(s), nil
case "date":
return validateDate(value), nil
case "after":
return v.compareDates(value, rule.args[0], ">"), nil
case "after_or_equal":
return v.compareDates(value, rule.args[0], ">="), nil
case "before":
return v.compareDates(value, rule.args[0], "<"), nil
case "before_or_equal":
return v.compareDates(value, rule.args[0], "<="), nil
case "exists":
return v.exists(attr, rule, value)
case "regex", "not_regex":
s, ok := regexSubject(value)
if !ok {
return false, nil
}
matched := rule.re.re.MatchString(s)
if rule.name == "regex" {
return matched, nil
}
return !matched, nil
case "in":
return v.validateIn(attr, value, rule.args), nil
case "not_in":
return v.validateNotIn(attr, value, rule.args), nil
case "file":
_, ok := asUploadedFile(value)
return ok, nil
case "image":
return validateMimes(value, imageExtensions), nil
case "mimes":
return validateMimes(value, rule.args), nil
case "min", "max", "size", "between":
size, ok := v.size(attr, value)
if !ok {
return false, nil
}
return sizeInRange(rule, size), nil
}
return false, fmt.Errorf("lagoon: rule %q is not implemented", rule.name)
}
func sizeInRange(rule Rule, size *big.Rat) bool {
bound := func(i int) *big.Rat {
r, _ := new(big.Rat).SetString(strings.TrimSpace(rule.args[i]))
return r
}
switch rule.name {
case "min":
return size.Cmp(bound(0)) >= 0
case "max":
return size.Cmp(bound(0)) <= 0
case "size":
return size.Cmp(bound(0)) == 0
default: // between
return size.Cmp(bound(0)) >= 0 && size.Cmp(bound(1)) <= 0
}
}
// size is Laravel's getSize: the number itself under a numeric rule, the
// element count of an array, kilobytes of a file, else the length of the
// string form in characters (PHP mb_strlen).
func (v *requestValidator) size(attr string, value any) (*big.Rat, bool) {
if isNumeric(value) && v.hasRule(attr, numericRuleNames...) {
s, _ := phpScalarString(value)
r, ok := new(big.Rat).SetString(phpTrim(s))
return r, ok
}
if n, ok := arrayLen(value); ok {
return new(big.Rat).SetInt64(int64(n)), true
}
if f, ok := asUploadedFile(value); ok {
return new(big.Rat).SetFrac64(f.Size, 1024), true
}
if value == nil {
return new(big.Rat), true
}
s, ok := phpScalarString(value)
if !ok {
return nil, false
}
return new(big.Rat).SetInt64(int64(utf8.RuneCountInString(s))), true
}
func validateRequired(value any) bool {
switch t := value.(type) {
case nil:
return false
case string:
return phpTrim(t) != ""
}
if n, ok := arrayLen(value); ok {
return n > 0
}
if f, ok := asUploadedFile(value); ok {
return f.Filename != "" || f.Size > 0
}
return true
}
func isAccepted(value any) bool {
switch t := value.(type) {
case bool:
return t
case string:
return t == "yes" || t == "on" || t == "1" || t == "true"
case json.Number:
return string(t) == "1"
case float64:
return t == 1
case int:
return t == 1
case int64:
return t == 1
}
return false
}
func validateArray(value any, keys []string) bool {
if _, ok := arrayLen(value); !ok {
return false
}
if len(keys) == 0 {
return true
}
allowed := map[string]bool{}
for _, k := range keys {
allowed[k] = true
}
for _, ch := range children(value) {
if !allowed[ch.key] {
return false
}
}
return true
}
func arrayLen(value any) (int, bool) {
switch t := value.(type) {
case []any:
return len(t), true
case map[string]any:
return len(t), true
case []string:
return len(t), true
case []map[string]any:
return len(t), true
}
rv := reflect.ValueOf(value)
if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Map {
return rv.Len(), true
}
return 0, false
}
func isStrictBoolean(value any) bool {
switch t := value.(type) {
case bool:
return true
case string:
return t == "0" || t == "1"
case json.Number:
return string(t) == "0" || string(t) == "1"
case float64:
return t == 0 || t == 1
case int:
return t == 0 || t == 1
case int64:
return t == 0 || t == 1
}
return false
}
// isNumeric ports PHP's is_numeric: numbers, and strings holding a decimal
// or exponent number with optional surrounding whitespace.
func isNumeric(value any) bool {
switch t := value.(type) {
case string:
return isNumericString(t)
case json.Number:
return isNumericString(string(t))
case float32:
return true
case float64:
return true
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return true
}
return false
}
func isNumericString(s string) bool {
s = strings.TrimLeft(s, " \t\n\r\v\f")
s = strings.TrimRight(s, " \t\n\r\v\f")
if s == "" {
return false
}
i := 0
if s[i] == '+' || s[i] == '-' {
i++
}
digits := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
digits++
}
if i < len(s) && s[i] == '.' {
i++
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
digits++
}
}
if digits == 0 {
return false
}
if i < len(s) && (s[i] == 'e' || s[i] == 'E') {
i++
if i < len(s) && (s[i] == '+' || s[i] == '-') {
i++
}
exp := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
exp++
}
if exp == 0 {
return false
}
}
return i == len(s)
}
// filterInt ports filter_var($value, FILTER_VALIDATE_INT) !== false: the
// string form, trimmed, must be an optionally signed decimal integer with no
// leading zero that fits in 64 bits. true counts as 1.
func filterInt(value any) bool {
var s string
switch t := value.(type) {
case bool:
return t
case nil:
return false
case int, int8, int16, int32, int64, uint8, uint16, uint32:
return true
case uint:
return uint64(t) <= math.MaxInt64
case uint64:
return t <= math.MaxInt64
case string:
s = t
case json.Number:
s = string(t)
if f, err := strconv.ParseFloat(s, 64); err == nil && strings.ContainsAny(s, ".eE") {
s = phpFloatString(f)
}
case float32:
s = phpFloatString(float64(t))
case float64:
s = phpFloatString(t)
default:
return false
}
s = strings.Trim(s, " \t\r\n\v\x00")
if s == "" {
return false
}
body := s
if body[0] == '+' || body[0] == '-' {
body = body[1:]
}
if body == "" {
return false
}
for i := 0; i < len(body); i++ {
if body[i] < '0' || body[i] > '9' {
return false
}
}
if len(body) > 1 && body[0] == '0' {
return false
}
_, err := strconv.ParseInt(s, 10, 64)
return err == nil
}
// phpScalarString is PHP's (string) cast for scalars.
func phpScalarString(value any) (string, bool) {
switch t := value.(type) {
case nil:
return "", true
case string:
return t, true
case json.Number:
if _, err := strconv.ParseInt(string(t), 10, 64); err == nil {
return string(t), true
}
if f, err := strconv.ParseFloat(string(t), 64); err == nil {
return phpFloatString(f), true
}
return string(t), true
case bool:
if t {
return "1", true
}
return "", true
case float32:
return phpFloatString(float64(t)), true
case float64:
return phpFloatString(t), true
case int:
return strconv.Itoa(t), true
case int8, int16, int32, int64:
return strconv.FormatInt(reflect.ValueOf(t).Int(), 10), true
case uint, uint8, uint16, uint32, uint64:
return strconv.FormatUint(reflect.ValueOf(t).Uint(), 10), true
}
return "", false
}
// phpFloatString formats a float as PHP 8 casts it to string: %.14G with
// the default precision ini of 14 (zend_gcvt). The value is correctly
// rounded to 14 significant digits and trailing zeros are dropped; it is
// written in exponent form (1.0E+15, 1.5E-5) when the decimal point would
// sit more than 14 digits right or more than 3 zeros left of the digits.
func phpFloatString(f float64) string {
switch {
case math.IsNaN(f):
return "NAN"
case math.IsInf(f, 1):
return "INF"
case math.IsInf(f, -1):
return "-INF"
case f == 0:
if math.Signbit(f) {
return "-0"
}
return "0"
}
const precision = 14
e := strconv.FormatFloat(math.Abs(f), 'e', precision-1, 64)
mant, expStr, _ := strings.Cut(e, "e")
exp, _ := strconv.Atoi(expStr)
digits := strings.TrimRight(strings.Replace(mant, ".", "", 1), "0")
if digits == "" {
digits = "0"
}
decpt := exp + 1
var out string
switch {
case decpt < -3 || decpt > precision:
m := digits[:1] + ".0"
if len(digits) > 1 {
m = digits[:1] + "." + digits[1:]
}
sign := "+"
if exp < 0 {
sign, exp = "-", -exp
}
out = m + "E" + sign + strconv.Itoa(exp)
case decpt <= 0:
out = "0." + strings.Repeat("0", -decpt) + digits
case decpt >= len(digits):
out = digits + strings.Repeat("0", decpt-len(digits))
default:
out = digits[:decpt] + "." + digits[decpt:]
}
if f < 0 {
out = "-" + out
}
return out
}
// phpTrim trims the characters PHP's trim() does.
func phpTrim(s string) string {
return strings.Trim(s, " \t\n\r\x00\x0B")
}
func regexSubject(value any) (string, bool) {
if s, ok := value.(string); ok {
return s, true
}
if isNumeric(value) {
return phpScalarString(value)
}
return "", false
}
func asUploadedFile(value any) (UploadedFile, bool) {
switch t := value.(type) {
case UploadedFile:
return t, true
case *UploadedFile:
if t != nil {
return *t, true
}
}
return UploadedFile{}, false
}
// in compares like PHP's in_array((string) $value, $parameters): two numeric
// strings compare as numbers, anything else as bytes.
func phpLooseStringEqual(a, b string) bool {
if a == b {
return true
}
if isNumericString(a) && isNumericString(b) {
ra, ok1 := new(big.Rat).SetString(phpTrim(a))
rb, ok2 := new(big.Rat).SetString(phpTrim(b))
return ok1 && ok2 && ra.Cmp(rb) == 0
}
return false
}
func inList(s string, list []string) bool {
for _, p := range list {
if phpLooseStringEqual(s, p) {
return true
}
}
return false
}
// validateIn is Laravel's validateIn. An array value needs the array rule
// and no nested element, and then, like count(array_diff($value,
// $parameters)) === 0, every element's string form must equal a parameter
// exactly. A scalar compares like in_array((string) $value, $parameters),
// where two numeric strings compare as numbers.
func (v *requestValidator) validateIn(attr string, value any, params []string) bool {
if _, isArr := arrayLen(value); isArr {
if !v.hasRule(attr, "array") {
return false
}
for _, ch := range children(value) {
if _, nested := arrayLen(ch.value); nested {
return false
}
}
for _, ch := range children(value) {
s, ok := phpScalarString(ch.value)
if !ok || !slices.Contains(params, s) {
return false
}
}
return true
}
s, ok := phpScalarString(value)
return ok && inList(s, params)
}
// validateNotIn is Laravel's validateNotIn: the negation of validateIn, so
// an array passes unless every element is listed, and an array without the
// array rule always passes.
func (v *requestValidator) validateNotIn(attr string, value any, params []string) bool {
return !v.validateIn(attr, value, params)
}
// exists is Laravel's exists:table,column presence check. It counts rows
// whose column, compared as text, equals the value (or, for an array, every
// distinct value); Laravel adds no deleted_at condition and neither does this.
func (v *requestValidator) exists(attr string, rule Rule, value any) (bool, error) {
if v.tx == nil {
return false, fmt.Errorf("lagoon: exists:%s requires a database handle", rule.args[0])
}
table := rule.args[0]
column := attr
if len(rule.args) == 2 && rule.args[1] != "NULL" {
column = rule.args[1]
} else if _, expanded := v.primary[attr]; expanded {
segs := strings.Split(attr, ".")
if last := segs[len(segs)-1]; !isNumericString(last) {
column = last
}
}
if !identName.MatchString(table) || !identName.MatchString(column) {
return false, fmt.Errorf("lagoon: exists identifier %q.%q is not safe", table, column)
}
db := v.tx.WithContext(v.ctx)
if _, isArr := arrayLen(value); isArr {
uniq := map[string]bool{}
var vals []string
for _, ch := range children(value) {
s, ok := phpScalarString(ch.value)
if !ok {
return false, nil
}
if !uniq[s] {
uniq[s] = true
vals = append(vals, s)
}
}
if len(vals) == 0 {
return true, nil
}
var n int64
err := db.Table(table).Where("CAST("+column+" AS TEXT) IN ?", vals).
Distinct("CAST(" + column + " AS TEXT)").Count(&n).Error
if err != nil {
return false, err
}
return n >= int64(len(vals)), nil
}
s, ok := phpScalarString(value)
if !ok {
return false, nil
}
var n int64
if err := db.Table(table).Where("CAST("+column+" AS TEXT) = ?", s).Count(&n).Error; err != nil {
return false, err
}
return n >= 1, nil
}
// validateMimes sniffs the uploaded content (http.DetectContentType, plus
// SVG detection) and maps the type to an extension the way Symfony's
// guessExtension does; jpg and jpeg stand for each other, and a client file
// name ending in a PHP extension is refused unless php is allowed.
func validateMimes(value any, allowed []string) bool {
f, ok := asUploadedFile(value)
if !ok || f.Open == nil {
return false
}
params := make([]string, 0, len(allowed)+2)
hasJPEG, hasPHP := false, false
for _, a := range allowed {
a = strings.ToLower(strings.TrimSpace(a))
params = append(params, a)
hasJPEG = hasJPEG || a == "jpg" || a == "jpeg"
hasPHP = hasPHP || a == "php"
}
if hasJPEG {
params = append(params, "jpg", "jpeg")
}
if !hasPHP {
ext := strings.ToLower(strings.TrimSpace(fileExtension(f.Filename)))
switch ext {
case "php", "php3", "php4", "php5", "php7", "php8", "phtml", "phar":
return false
}
}
guessed := guessExtension(f)
if guessed == "" {
return false
}
for _, p := range params {
if p == guessed {
return true
}
}
return false
}
func fileExtension(name string) string {
if i := strings.LastIndex(name, "."); i >= 0 {
return name[i+1:]
}
return ""
}
var mimeExtensions = map[string]string{
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp",
"image/x-ms-bmp": "bmp",
"image/svg+xml": "svg",
"image/x-icon": "ico",
"image/vnd.microsoft.icon": "ico",
"image/avif": "avif",
"application/pdf": "pdf",
"application/zip": "zip",
"application/x-gzip": "gz",
"application/x-rar-compressed": "rar",
"application/ogg": "ogx",
"application/wasm": "wasm",
"application/octet-stream": "bin",
"application/json": "json",
"text/plain": "txt",
"text/html": "html",
"text/xml": "xml",
"text/css": "css",
"audio/mpeg": "mp3",
"audio/wave": "wav",
"audio/aiff": "aif",
"video/mp4": "mp4",
"video/webm": "webm",
"video/avi": "avi",
"font/woff": "woff",
"font/woff2": "woff2",
"font/ttf": "ttf",
"font/otf": "otf",
}
func guessExtension(f UploadedFile) string {
rc, err := f.Open()
if err != nil {
return ""
}
defer rc.Close()
head := make([]byte, 512)
n, err := io.ReadFull(rc, head)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return ""
}
head = head[:n]
if n == 0 {
return ""
}
mime, _, _ := strings.Cut(http.DetectContentType(head), ";")
mime = strings.TrimSpace(mime)
if strings.HasPrefix(mime, "text/") && looksLikeSVG(head) {
mime = "image/svg+xml"
}
return mimeExtensions[mime]
}
func looksLikeSVG(head []byte) bool {
s := strings.ToLower(string(head))
return strings.Contains(s, "<svg")
}
// validateDate ports Laravel's date rule (strtotime then checkdate) for the
// date shapes listed in parseDateValue.
func validateDate(value any) bool {
s, ok := value.(string)
if !ok {
return false
}
_, ok = parseDateValue(s)
return ok
}
// compareDates ports Laravel's compareDates without date_format: the
// parameter is a date or relative word, else the name of another field
// whose value is the date. An unparsable side compares as PHP compares null
// with an integer (both as booleans).
func (v *requestValidator) compareDates(value any, param, op string) bool {
var vs string
switch t := value.(type) {
case string:
vs = t
default:
if !isNumeric(value) {
return false
}
vs, _ = phpScalarString(value)
}
var second *int64
if t, ok := parseDateArg(param); ok && t.Unix() != 0 {
ts := t.Unix()
second = &ts
} else if other, present := v.lookup(param); present {
if os, isStr := other.(string); isStr {
if t, ok := parseDateArg(os); ok {
ts := t.Unix()
second = &ts
}
}
}
var first *int64
if t, ok := parseDateArg(vs); ok {
ts := t.Unix()
first = &ts
}
return phpCompare(first, second, op)
}
func phpCompare(a, b *int64, op string) bool {
var c int
switch {
case a != nil && b != nil:
switch {
case *a < *b:
c = -1
case *a > *b:
c = 1
}
default:
ab := a != nil && *a != 0
bb := b != nil && *b != 0
switch {
case !ab && bb:
c = -1
case ab && !bb:
c = 1
}
}
switch op {
case ">":
return c > 0
case ">=":
return c >= 0
case "<":
return c < 0
default:
return c <= 0
}
}
// requestNow is the clock for relative date words; tests replace it.
var requestNow = time.Now
// parseDateArg parses a date rule parameter or value the way Carbon::parse
// does for the shapes parseDateValue accepts, plus the relative words today,
// tomorrow, yesterday and now (midnight or the current time, in UTC).
func parseDateArg(s string) (time.Time, bool) {
now := requestNow().UTC()
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
switch strings.ToLower(strings.TrimSpace(s)) {
case "now":
return now, true
case "today", "midnight":
return midnight, true
case "tomorrow":
return midnight.AddDate(0, 0, 1), true
case "yesterday":
return midnight.AddDate(0, 0, -1), true
}
return parseDateValue(s)
}
var (
dateISO = regexp.MustCompile(`^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?$`)
dateSlash = regexp.MustCompile(`^(\d{4})/(\d{1,2})/(\d{1,2})$`)
dateUS = regexp.MustCompile(`^(\d{1,2})/(\d{1,2})/(\d{4})$`)
dateDot = regexp.MustCompile(`^(\d{1,2})[.-](\d{1,2})[.-](\d{4})$`)
)
// parseDateValue accepts the date shapes the API clients send, all read in
// UTC unless an offset is given: Y-m-d, Y-m-d H:i[:s[.u]] and the ISO 8601
// form with a T, Z or an offset; Y/m/d; m/d/Y (strtotime's American slash
// order); d.m.Y and d-m-Y. The calendar date must exist (checkdate), so
// 2023-02-30 is refused. Other strtotime inputs are refused.
func parseDateValue(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
var y, mo, d, h, mi, sec, nsec int
loc := time.UTC
switch {
case dateISO.MatchString(s):
m := dateISO.FindStringSubmatch(s)
y, mo, d = atoi(m[1]), atoi(m[2]), atoi(m[3])
if m[4] != "" {
h, mi = atoi(m[4]), atoi(m[5])
}
if m[6] != "" {
sec = atoi(m[6])
}
if m[7] != "" {
frac := (m[7] + "000000000")[:9]
nsec = atoi(frac)
}
if z := m[8]; z != "" && z != "Z" {
sign := 1
if z[0] == '-' {
sign = -1
}
digits := strings.ReplaceAll(z[1:], ":", "")
oh := atoi(digits[:2])
om := 0
if len(digits) == 4 {
om = atoi(digits[2:])
}
if oh > 23 || om > 59 {
return time.Time{}, false
}
loc = time.FixedZone("", sign*(oh*3600+om*60))
}
case dateSlash.MatchString(s):
m := dateSlash.FindStringSubmatch(s)
y, mo, d = atoi(m[1]), atoi(m[2]), atoi(m[3])
case dateUS.MatchString(s):
m := dateUS.FindStringSubmatch(s)
mo, d, y = atoi(m[1]), atoi(m[2]), atoi(m[3])
case dateDot.MatchString(s):
m := dateDot.FindStringSubmatch(s)
d, mo, y = atoi(m[1]), atoi(m[2]), atoi(m[3])
default:
return time.Time{}, false
}
if !checkDate(y, mo, d) || h > 23 || mi > 59 || sec > 59 {
return time.Time{}, false
}
return time.Date(y, time.Month(mo), d, h, mi, sec, nsec, loc), true
}
func atoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}
func checkDate(y, m, d int) bool {
if y < 1 || y > 32767 || m < 1 || m > 12 || d < 1 {
return false
}
return d <= time.Date(y, time.Month(m)+1, 0, 0, 0, 0, 0, time.UTC).Day()
}
// filterEmail ports PHP's FILTER_VALIDATE_EMAIL, the check Winter's email
// rule uses by default: ASCII only, under 255 characters, a local part of
// dot-separated atoms or quoted strings up to 64 characters, and a domain of
// at least two dot-separated labels whose last starts with a letter (or is
// an xn-- label), or a bracketed IPv4 or IPv6 literal.
func filterEmail(s string) bool {
if s == "" || len(s) >= 255 {
return false
}
for i := 0; i < len(s); i++ {
if s[i] >= 0x80 {
return false
}
}
at := emailLocalEnd(s)
if at <= 0 || at >= len(s) || s[at] != '@' || at > 64 {
return false
}
return emailDomainOK(s[at+1:])
}
func isAtext(c byte) bool {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
return true
}
return strings.IndexByte("!#$%&'*+-/=?^_`{|}~", c) >= 0
}
// emailLocalEnd parses the local part and returns the index of the @ that
// ends it, or -1.
func emailLocalEnd(s string) int {
i := 0
for {
if i >= len(s) {
return -1
}
if s[i] == '"' {
i++
for {
if i >= len(s) {
return -1
}
c := s[i]
if c == '"' {
i++
break
}
if c == '\\' {
if i+1 >= len(s) || s[i+1] > 0x7F {
return -1
}
i += 2
continue
}
if c == 0 || c == '\t' || c == '\n' || c == '\r' || c == ' ' || c > 0x7F {
return -1
}
i++
}
} else {
start := i
for i < len(s) && isAtext(s[i]) {
i++
}
if i == start {
return -1
}
}
if i < len(s) && s[i] == '.' {
i++
continue
}
if i < len(s) && s[i] == '@' {
return i
}
return -1
}
}
var (
emailLabel = regexp.MustCompile(`(?i)^(?:xn--)?[a-z0-9]+(?:-+[a-z0-9]+)*$`)
emailTopLabel = regexp.MustCompile(`(?i)^(?:[a-z][a-z0-9]*|xn--[a-z0-9]+)(?:-+[a-z0-9]+)*$`)
emailIPv4 = regexp.MustCompile(`^(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$`)
)
func emailDomainOK(d string) bool {
if strings.HasPrefix(d, "[") && strings.HasSuffix(d, "]") {
lit := d[1 : len(d)-1]
if len(lit) > 5 && strings.EqualFold(lit[:5], "IPv6:") {
ip := net.ParseIP(lit[5:])
return ip != nil && strings.Contains(lit[5:], ":")
}
return emailIPv4.MatchString(lit)
}
labels := strings.Split(d, ".")
if len(labels) < 2 || len(labels) > 127 {
return false
}
for i, l := range labels {
if len(l) >= 64 {
return false
}
if i == len(labels)-1 {
if !emailTopLabel.MatchString(l) {
return false
}
continue
}
if !emailLabel.MatchString(l) {
return false
}
}
return true
}
// urlPattern is Laravel 9's validateUrl pattern (derived from Symfony's
// UrlValidator) rewritten for RE2: the same protocols, optional basic auth,
// a domain name, IPv4 or bracketed IPv6 host, optional port, path, query
// and fragment, matched case-insensitively.
var urlPattern = regexp.MustCompile(`(?i)^` +
`(aaa|aaas|about|acap|acct|acd|acr|adiumxtra|adt|afp|afs|aim|amss|android|appdata|apt|ark|attachment|aw|barion|beshare|bitcoin|bitcoincash|blob|bolo|browserext|calculator|callto|cap|cast|casts|chrome|chrome-extension|cid|coap|coap\+tcp|coap\+ws|coaps|coaps\+tcp|coaps\+ws|com-eventbrite-attendee|content|conti|crid|cvs|dab|data|dav|diaspora|dict|did|dis|dlna-playcontainer|dlna-playsingle|dns|dntp|dpp|drm|drop|dtn|dvb|ed2k|elsi|example|facetime|fax|feed|feedready|file|filesystem|finger|first-run-pen-experience|fish|fm|ftp|fuchsia-pkg|geo|gg|git|gizmoproject|go|gopher|graph|gtalk|h323|ham|hcap|hcp|http|https|hxxp|hxxps|hydrazone|iax|icap|icon|im|imap|info|iotdisco|ipn|ipp|ipps|irc|irc6|ircs|iris|iris\.beep|iris\.lwz|iris\.xpc|iris\.xpcs|isostore|itms|jabber|jar|jms|keyparc|lastfm|ldap|ldaps|leaptofrogans|lorawan|lvlt|magnet|mailserver|mailto|maps|market|message|mid|mms|modem|mongodb|moz|ms-access|ms-browser-extension|ms-calculator|ms-drive-to|ms-enrollment|ms-excel|ms-eyecontrolspeech|ms-gamebarservices|ms-gamingoverlay|ms-getoffice|ms-help|ms-infopath|ms-inputapp|ms-lockscreencomponent-config|ms-media-stream-id|ms-mixedrealitycapture|ms-mobileplans|ms-officeapp|ms-people|ms-project|ms-powerpoint|ms-publisher|ms-restoretabcompanion|ms-screenclip|ms-screensketch|ms-search|ms-search-repair|ms-secondary-screen-controller|ms-secondary-screen-setup|ms-settings|ms-settings-airplanemode|ms-settings-bluetooth|ms-settings-camera|ms-settings-cellular|ms-settings-cloudstorage|ms-settings-connectabledevices|ms-settings-displays-topology|ms-settings-emailandaccounts|ms-settings-language|ms-settings-location|ms-settings-lock|ms-settings-nfctransactions|ms-settings-notifications|ms-settings-power|ms-settings-privacy|ms-settings-proximity|ms-settings-screenrotation|ms-settings-wifi|ms-settings-workplace|ms-spd|ms-sttoverlay|ms-transit-to|ms-useractivityset|ms-virtualtouchpad|ms-visio|ms-walk-to|ms-whiteboard|ms-whiteboard-cmd|ms-word|msnim|msrp|msrps|mss|mtqp|mumble|mupdate|mvn|news|nfs|ni|nih|nntp|notes|ocf|oid|onenote|onenote-cmd|opaquelocktoken|openpgp4fpr|pack|palm|paparazzi|payto|pkcs11|platform|pop|pres|prospero|proxy|pwid|psyc|pttp|qb|query|redis|rediss|reload|res|resource|rmi|rsync|rtmfp|rtmp|rtsp|rtsps|rtspu|s3|secondlife|service|session|sftp|sgn|shttp|sieve|simpleledger|sip|sips|skype|smb|sms|smtp|snews|snmp|soap\.beep|soap\.beeps|soldat|spiffe|spotify|ssh|steam|stun|stuns|submit|svn|tag|teamspeak|tel|teliaeid|telnet|tftp|tg|things|thismessage|tip|tn3270|tool|ts3server|turn|turns|tv|udp|unreal|urn|ut2004|v-event|vemmi|ventrilo|videotex|vnc|view-source|wais|webcal|wpid|ws|wss|wtai|wyciwyg|xcon|xcon-userid|xfire|xmlrpc\.beep|xmlrpc\.beeps|xmpp|xri|ymsgr|z39\.50|z39\.50r|z39\.50s)://` +
`(((?:[_.\pL\pN-]|%[0-9A-Fa-f]{2})+:)?((?:[_.\pL\pN-]|%[0-9A-Fa-f]{2})+)@)?` +
`(` +
`([\pL\pN\pS\-_.])+(\.?([\pL\pN]|xn--[\pL\pN-]+)+\.?)` +
`|` +
`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}` +
`|` +
`\[` + urlIPv6 + `\]` +
`)` +
`(:[0-9]+)?` +
`(?:/(?:[\pL\pN\-._~!$&'()*+,;=:@]|%[0-9A-Fa-f]{2})*)*` +
`(?:\?(?:[\pL\pN\-._~!$&'\[\]()*+,;=:@/?]|%[0-9A-Fa-f]{2})*)?` +
`(?:#(?:[\pL\pN\-._~!$&'()*+,;=:@/?]|%[0-9A-Fa-f]{2})*)?` +
`$`)
const urlIPv6 = `(?:(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-f]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,1}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,2}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,3}(?:(?:[0-9a-f]{1,4})))?::(?:(?:[0-9a-f]{1,4})):)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,4}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,5}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,6}(?:(?:[0-9a-f]{1,4})))?::))))`