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.
1236 lines
36 KiB
Go
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})))?::))))`
|