- lagoon.ValidateRequest ports Laravel 9 request validation: wildcard expansion, implicit-rule stop, bail, sometimes/nullable/blank skipping, size messages split by type and character-counted string lengths - ParseRules, In, CustomRule, UploadedFile and ErrorKeys for rule tables - pl/en lagoon::validation catalogs ported verbatim from WinterCMS - lagoon.Validate answers a numeric range failure with the bound that failed (min, max or numeric between) instead of always max
1218 lines
35 KiB
Go
1218 lines
35 KiB
Go
package lagoon
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"reflect"
|
|
"regexp"
|
|
"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: the shortest
|
|
// round-trip digits, in exponent form (1.0E+15) from 1e15 up and below 1e-4.
|
|
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"
|
|
}
|
|
e := strconv.FormatFloat(f, 'e', -1, 64)
|
|
mant, expStr, _ := strings.Cut(e, "e")
|
|
exp, _ := strconv.Atoi(expStr)
|
|
if exp >= -4 && exp < 15 {
|
|
return strconv.FormatFloat(f, 'f', -1, 64)
|
|
}
|
|
if !strings.Contains(mant, ".") {
|
|
mant += ".0"
|
|
}
|
|
sign := "+"
|
|
if exp < 0 {
|
|
sign = "-"
|
|
exp = -exp
|
|
}
|
|
return mant + "E" + sign + strconv.Itoa(exp)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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
|
|
}
|
|
s, ok := phpScalarString(ch.value)
|
|
if !ok || !inList(s, params) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
s, ok := phpScalarString(value)
|
|
return ok && inList(s, params)
|
|
}
|
|
|
|
func (v *requestValidator) validateNotIn(attr string, value any, params []string) bool {
|
|
if _, isArr := arrayLen(value); isArr {
|
|
if !v.hasRule(attr, "array") {
|
|
return false
|
|
}
|
|
for _, ch := range children(value) {
|
|
s, ok := phpScalarString(ch.value)
|
|
if ok && inList(s, params) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
s, ok := phpScalarString(value)
|
|
return ok && !inList(s, 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})))?::))))`
|