package lagoon import ( "context" "fmt" "io" "mime/multipart" "net/textproto" "sort" "strconv" "strings" "unicode/utf8" "git.golem15.com/golem15/summercms/modules/phrasebook" "gorm.io/gorm" ) // RequestRule is one attribute of a request rule table: the attribute name, // which may contain `*` wildcard segments (posts.*.title), and its rules in // the order Laravel runs them. type RequestRule struct { Field string Rules []Rule } // Rule is one parsed validation rule: a named Laravel rule with its // parameters, or a closure built with CustomRule. Build rules with // ParseRules, In or CustomRule; the zero Rule is ignored. type Rule struct { name string args []string custom func(attribute string, value any) (message string, failed bool) re *compiledRegex } // Name returns the snake-case rule name (required, max, regex) or "custom" // for a CustomRule. func (r Rule) Name() string { if r.custom != nil { return "custom" } return r.name } // Args returns the rule parameters as written after the colon. func (r Rule) Args() []string { return append([]string(nil), r.args...) } // CustomRule wraps a closure rule, the Go form of a PHP `function // ($attribute, $value, $fail)` rule. It runs in its declaration position, // only when the value is present (it is not implicit), and a failure adds // the returned message as written, after the :attribute placeholder is // replaced, exactly as Winter adds a closure's $fail message. func CustomRule(fn func(attribute string, value any) (message string, failed bool)) Rule { if fn == nil { panic("lagoon: CustomRule with a nil func") } return Rule{custom: fn} } // In is the Go form of Laravel's Rule::in: the value must equal one of // values. Use it when a value contains a comma or a quote. func In(values ...string) Rule { return Rule{name: "in", args: append([]string(nil), values...)} } // UploadedFile is an uploaded file offered to the file rules (file, image, // mimes and the size rules, which measure it in kilobytes). Open returns the // content; the rules read at most the first 512 bytes to detect the type. type UploadedFile struct { Filename string Size int64 Header textproto.MIMEHeader Open func() (io.ReadCloser, error) } // UploadedFileFromHeader adapts a parsed multipart file part. func UploadedFileFromHeader(fh *multipart.FileHeader) UploadedFile { if fh == nil { return UploadedFile{} } return UploadedFile{ Filename: fh.Filename, Size: fh.Size, Header: fh.Header, Open: func() (io.ReadCloser, error) { return fh.Open() }, } } // ValidateRequest validates decoded request input with Laravel 9 request // validation semantics and returns Laravel's errors object (attribute to // ordered messages), or nil when the input passes. The error return is for // failures that are not the client's fault (a database error in exists:). // // The semantics follow Illuminate\Validation\Validator: `*` segments expand // against the input (posts.0.title, posts.1.title; a wildcard with nothing to // expand adds no attribute); rules run in order; a non-implicit rule runs only // when the attribute is present and is skipped for a blank string, for null // under nullable, and for an absent key under sometimes; an attribute stops // after a failed implicit rule (required, present, filled, accepted) and, // under bail, after any failure. Messages come from the lagoon::validation // catalog in the request locale; size rules pick the numeric, file, array or // string message by the attribute's type. func ValidateRequest(ctx context.Context, tx *gorm.DB, input map[string]any, rules []RequestRule, tr *phrasebook.Translator) (map[string][]string, error) { v := &requestValidator{ctx: ctx, tx: tx, data: input, tr: tr} if v.data == nil { v.data = map[string]any{} } if err := v.explode(rules); err != nil { return nil, err } for _, attr := range v.order { ruleset := v.rules[attr] for _, rule := range ruleset { if err := v.validateAttribute(attr, rule); err != nil { return nil, err } if v.shouldStop(attr) { break } } } if len(v.messages) == 0 { return nil, nil } return v.messages, nil } // ErrorKeys returns the attributes of errs in the order Laravel's message bag // holds them for rules: explicit attributes in declaration order, then the // attributes each wildcard rule expanded to, rule by rule, in input order // (array indexes ascending). Callers that compare a 422 body byte for byte // use it to emit the errors object in PHP's key order. func ErrorKeys(errs map[string][]string, rules []RequestRule) []string { type ranked struct { key string group int path []string } explicit := map[string]int{} for i, rr := range rules { if !strings.Contains(rr.Field, "*") { if _, ok := explicit[rr.Field]; !ok { explicit[rr.Field] = i } } } out := make([]ranked, 0, len(errs)) for key := range errs { group := len(rules) * 2 if i, ok := explicit[key]; ok { group = i } else { for i, rr := range rules { if strings.Contains(rr.Field, "*") && wildcardMatches(rr.Field, key) { group = len(rules) + i break } } } out = append(out, ranked{key: key, group: group, path: strings.Split(key, ".")}) } sort.SliceStable(out, func(i, j int) bool { if out[i].group != out[j].group { return out[i].group < out[j].group } return pathLess(out[i].path, out[j].path) }) keys := make([]string, len(out)) for i, r := range out { keys[i] = r.key } return keys } func pathLess(a, b []string) bool { for i := 0; i < len(a) && i < len(b); i++ { if a[i] == b[i] { continue } ai, aerr := strconv.Atoi(a[i]) bi, berr := strconv.Atoi(b[i]) if aerr == nil && berr == nil { return ai < bi } return a[i] < b[i] } return len(a) < len(b) } // wildcardMatches reports whether key is an expansion of pattern: every `*` // stands for one non-empty segment. func wildcardMatches(pattern, key string) bool { ps := strings.Split(pattern, ".") ks := strings.Split(key, ".") if len(ps) != len(ks) { return false } for i := range ps { if ps[i] == "*" { if ks[i] == "" { return false } continue } if ps[i] != ks[i] { return false } } return true } type requestValidator struct { ctx context.Context tx *gorm.DB data map[string]any tr *phrasebook.Translator order []string rules map[string][]Rule primary map[string]string // expanded attribute -> wildcard pattern messages map[string][]string failed map[string]map[string]bool } // explode builds the attribute order Laravel's ValidationRuleParser gives: // explicit attributes keep their declaration position (a later explicit // declaration of an expanded key replaces its rules), and wildcard // expansions are appended in expansion order. func (v *requestValidator) explode(rules []RequestRule) error { v.rules = map[string][]Rule{} v.primary = map[string]string{} seen := map[string]bool{} for _, rr := range rules { if rr.Field == "" { return fmt.Errorf("lagoon: request rule with an empty field") } if seen[rr.Field] { return fmt.Errorf("lagoon: duplicate request rule field %q", rr.Field) } seen[rr.Field] = true v.order = append(v.order, rr.Field) v.rules[rr.Field] = cleanRules(rr.Rules) } for _, rr := range rules { if !strings.Contains(rr.Field, "*") { v.rules[rr.Field] = cleanRules(rr.Rules) continue } for _, key := range expandWildcard(v.data, rr.Field) { if _, ok := v.rules[key]; ok { v.rules[key] = append(v.rules[key], cleanRules(rr.Rules)...) } else { v.order = append(v.order, key) v.rules[key] = cleanRules(rr.Rules) } if _, ok := v.primary[key]; !ok { v.primary[key] = rr.Field } } delete(v.rules, rr.Field) v.order = removeString(v.order, rr.Field) } return nil } func cleanRules(rules []Rule) []Rule { out := make([]Rule, 0, len(rules)) for _, r := range rules { if r.name == "" && r.custom == nil { continue } out = append(out, r) } return out } func removeString(list []string, s string) []string { out := list[:0] for _, x := range list { if x != s { out = append(out, x) } } return out } type expandedKey struct { key string leaf bool } // expandWildcard lists the concrete attributes a wildcard pattern stands for // in data, in the order Laravel's ValidationData gathers them: attributes that // are leaves of the dotted input first, then the rest, each in input order. // Literal segments after the last `*` always produce the attribute (its value // may be missing); a missing or scalar node before a `*` produces nothing. func expandWildcard(data map[string]any, pattern string) []string { segs := strings.Split(pattern, ".") last := -1 for i, s := range segs { if s == "*" { last = i } } var found []expandedKey var walk func(node any, present bool, i int, prefix string) walk = func(node any, present bool, i int, prefix string) { if i == len(segs) { found = append(found, expandedKey{key: prefix, leaf: !present || isDotLeaf(node)}) return } seg := segs[i] if seg == "*" { for _, ch := range children(node) { walk(ch.value, true, i+1, joinPath(prefix, ch.key)) } return } child, ok := childOf(node, seg) if i > last { walk(child, ok, i+1, joinPath(prefix, seg)) return } if !ok { return } walk(child, true, i+1, joinPath(prefix, seg)) } walk(data, true, 0, "") out := make([]string, 0, len(found)) seen := map[string]bool{} for _, pass := range []bool{true, false} { for _, f := range found { if f.leaf != pass || seen[f.key] { continue } seen[f.key] = true out = append(out, f.key) } } return out } func joinPath(prefix, seg string) string { if prefix == "" { return seg } return prefix + "." + seg } // isDotLeaf reports whether Arr::dot would emit the value as one key: any // scalar and any empty array. func isDotLeaf(v any) bool { return len(children(v)) == 0 } type childEntry struct { key string value any } // children lists an array's elements or a map's entries. Go maps carry no // insertion order, so map entries come sorted by key. func children(v any) []childEntry { switch t := v.(type) { case []any: out := make([]childEntry, len(t)) for i, x := range t { out[i] = childEntry{key: strconv.Itoa(i), value: x} } return out case map[string]any: keys := make([]string, 0, len(t)) for k := range t { keys = append(keys, k) } sort.Strings(keys) out := make([]childEntry, len(keys)) for i, k := range keys { out[i] = childEntry{key: k, value: t[k]} } return out case []string: out := make([]childEntry, len(t)) for i, x := range t { out[i] = childEntry{key: strconv.Itoa(i), value: x} } return out case []map[string]any: out := make([]childEntry, len(t)) for i, x := range t { out[i] = childEntry{key: strconv.Itoa(i), value: x} } return out } return nil } func childOf(v any, seg string) (any, bool) { switch t := v.(type) { case map[string]any: x, ok := t[seg] return x, ok case []any: i, err := strconv.Atoi(seg) if err != nil || i < 0 || i >= len(t) || strconv.Itoa(i) != seg { return nil, false } return t[i], true case []string: i, err := strconv.Atoi(seg) if err != nil || i < 0 || i >= len(t) || strconv.Itoa(i) != seg { return nil, false } return t[i], true case []map[string]any: i, err := strconv.Atoi(seg) if err != nil || i < 0 || i >= len(t) || strconv.Itoa(i) != seg { return nil, false } return t[i], true } return nil, false } // lookup is Arr::get/Arr::has over the dotted attribute. func (v *requestValidator) lookup(attr string) (any, bool) { var node any = v.data for _, seg := range strings.Split(attr, ".") { x, ok := childOf(node, seg) if !ok { return nil, false } node = x } return node, true } func (v *requestValidator) hasRule(attr string, names ...string) bool { for _, r := range v.rules[attr] { for _, n := range names { if r.custom == nil && r.name == n { return true } } } return false } func (v *requestValidator) validateAttribute(attr string, rule Rule) error { value, present := v.lookup(attr) if !v.isValidatable(rule, attr, value, present) { return nil } if rule.custom != nil { msg, failed := rule.custom(attr, value) if failed { v.addMessage(attr, v.replaceAttribute(msg, attr)) v.markFailed(attr, "custom") } return nil } ok, err := v.passes(rule, attr, value, present) if err != nil { return err } if !ok { v.addFailure(attr, rule, value) } return nil } func (v *requestValidator) isValidatable(rule Rule, attr string, value any, present bool) bool { implicit := rule.isImplicit() // presentOrRuleIsImplicit if s, ok := value.(string); ok && phpTrim(s) == "" && present { if !implicit { return false } } else if !present && !implicit { return false } // passesOptionalCheck if v.hasRule(attr, "sometimes") && !present { return false } // isNotNullIfMarkedAsNullable if !implicit && v.hasRule(attr, "nullable") && present && value == nil { return false } // hasNotFailedPreviousRuleIfPresenceRule if rule.custom == nil && (rule.name == "exists" || rule.name == "unique") && len(v.messages[attr]) > 0 { return false } return true } func (v *requestValidator) shouldStop(attr string) bool { if v.hasRule(attr, "bail") { return len(v.messages[attr]) > 0 } if !v.hasRule(attr, implicitRuleNames...) { return false } for name := range v.failed[attr] { if isImplicitName(name) { return true } } return false } func (v *requestValidator) addMessage(attr, msg string) { if v.messages == nil { v.messages = map[string][]string{} } v.messages[attr] = append(v.messages[attr], msg) } func (v *requestValidator) markFailed(attr, rule string) { if v.failed == nil { v.failed = map[string]map[string]bool{} } if v.failed[attr] == nil { v.failed[attr] = map[string]bool{} } v.failed[attr][rule] = true } func (v *requestValidator) addFailure(attr string, rule Rule, value any) { msg := v.message(attr, rule.name) msg = v.replaceAttribute(msg, attr) msg = replaceInput(msg, value) msg = replaceIndexes(msg, attr) msg = v.replaceRule(msg, attr, rule) v.addMessage(attr, msg) v.markFailed(attr, rule.name) } const catalogPrefix = "lagoon::validation." // message is Winter's FormatsMessages::getMessage: a custom line for the // attribute and rule, then the typed line of a size rule, then the rule line. func (v *requestValidator) message(attr, rule string) string { if s, ok := v.line("custom." + attr + "." + rule); ok { return s } if sizeRuleNames[rule] { if s, ok := v.line(rule + "." + v.attributeType(attr)); ok { return s } return "validation." + rule + "." + v.attributeType(attr) } if s, ok := v.line(rule); ok { return s } return "validation." + rule } func (v *requestValidator) line(key string) (string, bool) { if v.tr == nil { return "", false } full := catalogPrefix + key if !v.tr.Has(full) { return "", false } s := v.tr.Get(v.ctx, full, nil) if s == full { return "", false } return s, true } func (v *requestValidator) attributeType(attr string) string { switch { case v.hasRule(attr, numericRuleNames...): return "numeric" case v.hasRule(attr, "array"): return "array" } if val, ok := v.lookup(attr); ok { if _, isFile := asUploadedFile(val); isFile { return "file" } } return "string" } // displayableAttribute is Laravel's getDisplayableAttribute: a catalog // attribute name for the attribute or its wildcard pattern, the raw name of // an expanded attribute, else the snake-cased name with spaces. func (v *requestValidator) displayableAttribute(attr string) string { names := []string{attr} primary, expanded := v.primary[attr] if expanded && primary != attr { names = append(names, primary) } for _, n := range names { if s, ok := v.line("attributes." + n); ok { return s } } if expanded { return attr } return strings.ReplaceAll(laravelSnake(attr), "_", " ") } func (v *requestValidator) replaceAttribute(msg, attr string) string { name := v.displayableAttribute(attr) return strings.NewReplacer( ":attribute", name, ":ATTRIBUTE", strings.ToUpper(name), ":Attribute", phpUcfirst(name), ).Replace(msg) } func (v *requestValidator) replaceRule(msg, attr string, rule Rule) string { switch rule.name { case "between": return strings.NewReplacer(":min", argAt(rule.args, 0), ":max", argAt(rule.args, 1)).Replace(msg) case "min": return strings.ReplaceAll(msg, ":min", argAt(rule.args, 0)) case "max": return strings.ReplaceAll(msg, ":max", argAt(rule.args, 0)) case "size": return strings.ReplaceAll(msg, ":size", argAt(rule.args, 0)) case "in", "not_in", "mimes": return strings.ReplaceAll(msg, ":values", strings.Join(rule.args, ", ")) case "after", "after_or_equal", "before", "before_or_equal": arg := argAt(rule.args, 0) if _, ok := parseDateArg(arg); !ok { return strings.ReplaceAll(msg, ":date", v.displayableAttribute(arg)) } return strings.ReplaceAll(msg, ":date", arg) } return msg } func argAt(args []string, i int) string { if i < len(args) { return args[i] } return "" } // replaceInput replaces :input with a scalar value, as Laravel does. func replaceInput(msg string, value any) string { if !strings.Contains(msg, ":input") { return msg } s, ok := phpScalarString(value) if !ok { return msg } return strings.ReplaceAll(msg, ":input", s) } // replaceIndexes replaces :index and :position (zero- and one-based) with // the first numeric segment of the attribute. func replaceIndexes(msg, attr string) string { if !strings.Contains(msg, ":index") && !strings.Contains(msg, ":position") { return msg } for _, seg := range strings.Split(attr, ".") { if n, err := strconv.Atoi(seg); err == nil { msg = strings.ReplaceAll(msg, ":index", strconv.Itoa(n)) msg = strings.ReplaceAll(msg, ":position", strconv.Itoa(n+1)) break } } return msg } // laravelSnake ports Str::snake with the underscore delimiter. func laravelSnake(s string) string { if isCtypeLower(s) { return s } s = phpUcwords(s) var b strings.Builder first := true for _, r := range s { if r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f' || r == '\v' { continue } if !first && r >= 'A' && r <= 'Z' { b.WriteByte('_') } b.WriteRune(r) first = false } return strings.ToLower(b.String()) } func isCtypeLower(s string) bool { if s == "" { return false } for i := 0; i < len(s); i++ { if s[i] < 'a' || s[i] > 'z' { return false } } return true } func phpUcwords(s string) string { b := []byte(s) start := true for i, c := range b { if start && c >= 'a' && c <= 'z' { b[i] = c - 'a' + 'A' } start = c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == '\v' } return string(b) } // phpUcfirst ports Str::ucfirst, which upper-cases the first character // multibyte-safely. func phpUcfirst(s string) string { r, size := utf8.DecodeRuneInString(s) if size == 0 || r == utf8.RuneError { return s } return strings.ToUpper(string(r)) + s[size:] }