diff --git a/docs/database/casts-and-validation.md b/docs/database/casts-and-validation.md
index f1bd132..01f68ea 100644
--- a/docs/database/casts-and-validation.md
+++ b/docs/database/casts-and-validation.md
@@ -1,6 +1,6 @@
---
title: Casts and validation
-description: Store JSON and encrypted columns with lagoon.Jsonable and lagoon.Encrypted, and validate input with Laravel-style rule strings through lagoon.Validate.
+description: Store JSON and encrypted columns with lagoon.Jsonable and lagoon.Encrypted, and validate input with lagoon.Validate and lagoon.ValidateRequest.
section: database
order: 50
---
@@ -94,11 +94,28 @@ fmt.Println(string(out))
// {"title":["The title field is required."],"views":["The views may not be greater than 1000."]}
```
-The supported rules are `required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different` and `mimes`. Any other rule is an error, so a rule that SummerCMS does not implement cannot be skipped by accident. On a field that is `integer` or `numeric`, `min`, `max` and `between` compare the number; on other fields they compare the length.
-
-> [!NOTE]
-> A failed numeric range check is currently always reported with the `max` message, even when the value is below `min`, and a `min`-only rule then shows an empty limit. Check range errors by field, not by message text.
+The supported rules are `required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different` and `mimes`. Any other rule is an error, so a rule that SummerCMS does not implement cannot be skipped by accident. On a field that is `integer` or `numeric`, `min`, `max` and `between` compare the number; on other fields they compare the length. A number below the lower bound gets the `min` message, one above the upper bound the `max` message, and a bound that came from `between` gets the numeric `between` message (`The views must be between 0 and 10.`).
`unique:
` runs a query to check that no other row of the table has the value in the field's column, so it needs a database handle; pass the transaction you are writing in. Soft-deleted rows do not count, and when the model you pass has an ID, its own row does not count either, so the same rules work for create and update. Pass a `phrasebook.Translator` as the last argument to get the messages in the request locale from the `lagoon::validate` catalog; with `nil` they are in English. See [Localization](../services/localization.md).
A handler validates before it fills and saves the model, as the create example on [Models](models.md) shows. Answer a non-nil error map with status 422 and the body shape the endpoint's existing clients expect.
+
+## Request validation
+
+`lagoon.Validate` checks a model's rules. A ported API endpoint has a different job: its 422 body has to match the one the PHP endpoint returns, message for message, in the request's language. `lagoon.ValidateRequest` does that by following Laravel 9's request validator rather than the model rules.
+
+It takes the decoded request input and a rule table: a slice of `lagoon.RequestRule`, one per attribute, in the order the PHP controller declares them. Build each attribute's rules from the PHP rule string with `lagoon.ParseRules`, add `lagoon.In` for an `in` list whose values hold commas or quotes, and `lagoon.CustomRule` for a PHP closure rule. A closure's failure message is used exactly as the closure returns it. Keep rule tables in package variables: `lagoon.ParseRules` panics on an unknown rule, a wrong number of parameters or a regular expression that Go cannot compile, so a mistake stops the application at start-up instead of failing a request.
+
+The behaviour follows Laravel:
+
+- An attribute name may contain `*`. It is expanded against the input, so `posts.*.title` checks `posts.0.title`, `posts.1.title` and so on, and the messages name those attributes. A wildcard with nothing to expand checks nothing.
+- The rules of an attribute run in order. After a failed `required` (or another implicit rule: `present`, `filled`, `accepted`) the attribute stops, so `{"posts":[]}` against `required|array|min:1` gives the single message `The posts field is required.`. With `bail`, any failure stops the attribute.
+- The other rules run only when there is a value: they skip an absent attribute, a blank string, `null` under `nullable` and an absent key under `sometimes`.
+- `min`, `max`, `size` and `between` measure what Laravel measures: the number on an `integer` or `numeric` attribute, compared exactly as a decimal; the number of elements of an array; the size of an uploaded file in kilobytes; otherwise the length in characters, not bytes. Each picks the matching message, such as `max.string` or `max.file`.
+- `email` is PHP's `FILTER_VALIDATE_EMAIL`, the check WinterCMS uses by default, so `user@localhost` is refused. `date` accepts ISO 8601 dates and date-times, `Y/m/d`, `m/d/Y`, `d.m.Y` and `d-m-Y`, and refuses dates that do not exist. `after_or_equal` and `before_or_equal` take a date, `today`, `tomorrow`, `yesterday` or `now` (in UTC), or the name of another field.
+- `exists:table,column` counts matching rows in the database, so it needs the transaction handle. Like Laravel it does not skip soft-deleted rows, and it does not run once the attribute already has a message.
+- File rules (`file`, `image`, `mimes` and the size rules) take a `lagoon.UploadedFile`; `lagoon.UploadedFileFromHeader` builds one from a parsed multipart part. The type is read from the file content, not from its name.
+
+The messages come from the `lagoon::validation` catalog, a copy of WinterCMS's validator messages in English and Polish, in the request locale. A message missing in Polish, such as the one for `after_or_equal`, comes out in English, as it does in WinterCMS. Attribute names show with spaces instead of underscores (`market price`), and expanded attributes keep their dotted name.
+
+The result is Laravel's errors object, a map from attribute to messages, or `nil` when the input passes. Go maps have no order, so when an endpoint's body must list the attributes in PHP's order, `lagoon.ErrorKeys` returns them in that order for the rule table.
diff --git a/docs/services/localization.md b/docs/services/localization.md
index 68d459b..848c841 100644
--- a/docs/services/localization.md
+++ b/docs/services/localization.md
@@ -81,4 +81,4 @@ The locale lives on the request context, not in a global. For every route, [surf
The admin SPA receives its strings from the server. `phrasebook.Translator.Bundle` returns every key under a prefix as CLDR plural forms, merged over the fallback chain, and `phrasebook.Translator.Forms` returns one key. Start-up fails if an admin (`backend::`) string cannot be expressed as CLDR forms, so a pipe string with a condition the SPA cannot evaluate is caught before any admin sees it.
-The framework ships its validation messages (`lagoon::validate`) and admin strings (`backend::lang`) in English and Polish.
+The framework ships its validation messages (`lagoon::validate` for `lagoon.Validate`, and the full WinterCMS validator catalog `lagoon::validation` for `lagoon.ValidateRequest`) and admin strings (`backend::lang`) in English and Polish.
diff --git a/modules/lagoon/README.md b/modules/lagoon/README.md
index 0c7db02..03f0f54 100644
--- a/modules/lagoon/README.md
+++ b/modules/lagoon/README.md
@@ -17,7 +17,8 @@ Postgres data layer: the shared GORM connection, per-plugin migrations, model he
- After-commit work: `lagoon.Transaction` runs a function in a transaction and then the callbacks registered with `lagoon.AfterCommit`, in order, only after the commit succeeds; a nested `lagoon.Transaction` is a savepoint whose callbacks are dropped with it when it fails. A nested `lagoon.Transaction` must be given the outer transaction's handle: given a root handle it returns an error without running its function, rather than open an independent transaction whose callbacks would wait on the outer one. A single-statement write for which GORM opens its own implicit transaction runs its callbacks from `lagoon:after_commit` once GORM commits, and never when the write fails. A callback registered inside a foreign plain GORM transaction is unsafe because Lagoon cannot observe its commit, so `lagoon.AfterCommit` warns and skips it. Outside a transaction, callbacks run immediately. The handle a supported callback receives always has an empty statement on the connection its work belongs to. A panicking callback is logged and never turns a committed write into an error.
- Per-plugin migrations: `lagoon.Migrate` runs the framework's `system_files` set (`attach.Migrations`), backend admin identity set (`lagoon.BackendAdminMigrations`) and job-queue set (`lagoon.QueueMigrations`: River's schema pinned at `lagoon.RiverSchemaVersion`, then the `lagoon.JobsTable` record table, under the `lagoon.QueueHistoryID` history), then every `pact.HasMigrations` set in plugin activation order, each in its own `summer_migrations_` history table (`lagoon.HistoryTableName`). `lagoon.RollbackLast` and `lagoon.Status` cover rollback and history.
- Mass assignment: `lagoon.Fill` copies only allow-listed keys onto a model by GORM column name and silently drops the rest, logging each dropped key once outside production. A `json.Number` (from a decoder using `UseNumber`) fills integer, unsigned and float fields. A value that does not fit its column (a fraction, an exponent or an overflow for an integer field, or a value of the wrong type) is a `lagoon.FillTypeError` naming the key, so a caller can answer it as a validation failure on that field. `lagoon.HasFillable` and `lagoon.HasHidden` are the Go forms of `$fillable` and `$hidden`.
-- Validation: `lagoon.Validate` accepts Laravel-style rule strings (`required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different`, `mimes`) and returns a field-to-messages map, translated through phrasebook when a translator is given. Unknown rule tokens are an error.
+- Validation: `lagoon.Validate` accepts Laravel-style rule strings (`required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different`, `mimes`) and returns a field-to-messages map, translated through phrasebook when a translator is given. Unknown rule tokens are an error. A failed numeric range reports the bound that failed: the `min` message below the lower bound, the `max` message above the upper one, and the numeric `between` message when the bound came from `between`.
+- Request validation: `lagoon.ValidateRequest` reproduces Laravel 9 request validation for ported API endpoints, so a 422 body matches the PHP one message for message. It takes the decoded input and an ordered `lagoon.RequestRule` table (attribute names may hold `*` wildcards, expanded against the input to `posts.0.title`), runs the rules of each attribute in order and stops an attribute after a failed implicit rule (`required`, `present`, `filled`, `accepted`) or, under `bail`, after any failure. A non-implicit rule is skipped for an absent attribute, a blank string, a null value under `nullable` and an absent key under `sometimes`. Supported rules: `required`, `present`, `filled`, `accepted`, `nullable`, `sometimes`, `bail`, `array`, `string`, `integer`, `numeric`, `boolean`, `email` (PHP `FILTER_VALIDATE_EMAIL`, WinterCMS's default), `url`, `date`, `after`, `after_or_equal`, `before`, `before_or_equal` (a date, a relative word such as `tomorrow`, or another field), `exists:table,column`, `regex`, `not_regex`, `in`, `not_in`, `file`, `image`, `mimes`, `min`, `max`, `size` and `between`, plus closure rules built with `lagoon.CustomRule`. The size rules compare the number under `numeric` or `integer` (exactly, as decimals), the element count of an array, kilobytes of a `lagoon.UploadedFile`, and otherwise the length in characters, and pick the matching message. Messages come from the `lagoon::validation` catalog in the request locale; `lagoon.ErrorKeys` gives the attribute order of PHP's message bag.
- Safe ordering: `lagoon.OrderBy` appends an ORDER BY only for an allow-listed column and an `asc` or `desc` direction, and `lagoon.Collate` adds a validated `COLLATE` clause for language-specific text order (for example the ICU collation `pl-x-icu`); lagoon puts no requirement on the database's default locale.
- Pagination: `lagoon.Paginate` builds a `lagoon.Page` with `data` and `meta` (`current_page`, `last_page`, `per_page`, `total`).
- Column types: `lagoon.Encrypted` stores AES-256-GCM ciphertext under a key derived from `app.key`, decrypts with previous keys during rotation, and always redacts itself in JSON and string output; `lagoon.Jsonable` stores JSON as TEXT and keeps SQL NULL distinct from an empty value.
@@ -135,6 +136,14 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `lagoon.Fill` | Allow-listed mass assignment by column name. |
| `lagoon.FillTypeError` | Returned by `lagoon.Fill` when a requested value does not fit its column; `Key` names the column. |
| `lagoon.Validate` | Laravel-style rule validation with a `unique` database check. |
+| `lagoon.ValidateRequest` | Laravel 9 request validation of decoded input against an ordered rule table; returns Laravel's errors object. |
+| `lagoon.RequestRule` | One attribute of a request rule table: `Field` (wildcards allowed) and its ordered `Rules`. |
+| `lagoon.Rule` | One parsed rule; `lagoon.Rule.Name` and `lagoon.Rule.Args` describe it. |
+| `lagoon.ParseRules` | Parses a Laravel rule string into rules; keeps `regex:` patterns whole and panics on an unknown rule or an invalid pattern, so rule tables fail at boot. |
+| `lagoon.In` | The `in` rule from a list of values, for values that contain commas or quotes (Laravel's `Rule::in`). |
+| `lagoon.CustomRule` | A closure rule; its failure message is used as written. |
+| `lagoon.UploadedFile` | An uploaded file for the file rules; `lagoon.UploadedFileFromHeader` adapts a `multipart.FileHeader`. |
+| `lagoon.ErrorKeys` | The attributes of an errors object in Laravel's order for a rule table. |
| `lagoon.OrderBy` | Allow-listed ORDER BY, with an optional `lagoon.Collate`. |
| `lagoon.Collate` | Order option that sorts the column with a named PostgreSQL collation; the name is validated and quoted. |
| `lagoon.OrderOption` | Option type accepted by `lagoon.OrderBy`. |
diff --git a/modules/lagoon/validate.go b/modules/lagoon/validate.go
index cf38918..0c3fcbf 100644
--- a/modules/lagoon/validate.go
+++ b/modules/lagoon/validate.go
@@ -146,8 +146,8 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
}
return []string{validateMessage(ctx, tr, ruleName, field, nil)}, nil
}
- if !moneyInRange(s, rangeMin, rangeMax) {
- return []string{validateMessage(ctx, tr, "max", field, map[string]string{"max": rangeMax, "min": rangeMin})}, nil
+ if msg, failed := numericRangeMessage(ctx, tr, field, s, rangeMin, rangeMax, minArg == "" && betweenMin != "", maxArg == "" && betweenMax != ""); failed {
+ return []string{msg}, nil
}
tags = withoutTag(tags, "numeric")
}
@@ -202,6 +202,38 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
return nil, nil
}
+// numericRangeMessage checks a numeric value against its bounds and answers
+// a failure with the message of the bound that failed: min below the lower
+// bound, max above the upper, and Laravel's numeric between message when
+// that bound came from between.
+func numericRangeMessage(ctx context.Context, tr *phrasebook.Translator, field, val, lo, hi string, loFromBetween, hiFromBetween bool) (string, bool) {
+ r := new(big.Rat)
+ if _, ok := r.SetString(val); !ok {
+ return validateMessage(ctx, tr, "max", field, map[string]string{"max": hi, "min": lo}), true
+ }
+ below, above := false, false
+ if lo != "" {
+ if m, ok := new(big.Rat).SetString(lo); ok && r.Cmp(m) < 0 {
+ below = true
+ }
+ }
+ if hi != "" {
+ if m, ok := new(big.Rat).SetString(hi); ok && r.Cmp(m) > 0 {
+ above = true
+ }
+ }
+ params := map[string]string{"min": lo, "max": hi}
+ switch {
+ case (below && loFromBetween) || (above && hiFromBetween):
+ return validateMessage(ctx, tr, "between.numeric", field, params), true
+ case below:
+ return validateMessage(ctx, tr, "min", field, params), true
+ case above:
+ return validateMessage(ctx, tr, "max", field, params), true
+ }
+ return "", false
+}
+
func splitRule(rule string) []string {
var out []string
for _, p := range strings.Split(rule, "|") {
@@ -307,33 +339,6 @@ func numericString(val any) (string, bool) {
}
}
-func moneyInRange(val any, min, max string) bool {
- s, ok := numericString(val)
- if !ok {
- s = strings.TrimSpace(fmt.Sprint(val))
- if s == "" || s == "" {
- return true
- }
- }
- r := new(big.Rat)
- if _, ok := r.SetString(s); !ok {
- return false
- }
- if min != "" {
- m := new(big.Rat)
- if _, ok := m.SetString(min); ok && r.Cmp(m) < 0 {
- return false
- }
- }
- if max != "" {
- m := new(big.Rat)
- if _, ok := m.SetString(max); ok && r.Cmp(m) > 0 {
- return false
- }
- }
- return true
-}
-
func uniqueOK(tx *gorm.DB, model any, table, column string, val any) (bool, error) {
if tx == nil {
return false, fmt.Errorf("lagoon: unique:%s requires a database handle", table)
@@ -405,6 +410,9 @@ func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field
}
params["attribute"] = laravelAttribute(field)
key := "lagoon::validate." + rule
+ if rule == "between.numeric" {
+ key = "lagoon::validation.between.numeric"
+ }
if tr != nil {
s := tr.Get(ctx, key, params)
if s != "" && s != key {
@@ -437,6 +445,8 @@ func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field
return "The " + attr + " must be a file of the allowed types."
case "between":
return "The " + attr + " must be between " + params["min"] + " and " + params["max"] + " characters."
+ case "between.numeric":
+ return "The " + attr + " must be between " + params["min"] + " and " + params["max"] + "."
case "boolean":
return "The " + attr + " field must be true or false."
default:
diff --git a/modules/lagoon/validate_request.go b/modules/lagoon/validate_request.go
new file mode 100644
index 0000000..ab91eb1
--- /dev/null
+++ b/modules/lagoon/validate_request.go
@@ -0,0 +1,732 @@
+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:]
+}
diff --git a/modules/lagoon/validate_request_test.go b/modules/lagoon/validate_request_test.go
new file mode 100644
index 0000000..cd9bf19
--- /dev/null
+++ b/modules/lagoon/validate_request_test.go
@@ -0,0 +1,365 @@
+package lagoon
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "testing/fstest"
+ "time"
+
+ "git.golem15.com/golem15/summercms/modules/phrasebook"
+ "git.golem15.com/golem15/summercms/modules/towel"
+)
+
+// requestTranslator loads the framework's lagoon::validation and
+// lagoon::validate catalogs from the phrasebook package directory.
+func requestTranslator(t *testing.T) *phrasebook.Translator {
+ t.Helper()
+ files := fstest.MapFS{}
+ for _, loc := range []string{"en", "pl"} {
+ for _, group := range []string{"validation", "validate"} {
+ rel := filepath.Join("lang", loc, group+".yaml")
+ raw, err := os.ReadFile(filepath.Join("..", "phrasebook", rel))
+ if err != nil {
+ t.Fatal(err)
+ }
+ files[filepath.ToSlash(rel)] = &fstest.MapFile{Data: raw}
+ }
+ }
+ cat := phrasebook.NewCatalog()
+ if err := cat.Load("lagoon", files); err != nil {
+ t.Fatal(err)
+ }
+ return phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
+}
+
+func inLocale(locale string) context.Context {
+ return towel.WithLocale(context.Background(), locale)
+}
+
+func mustValidate(t *testing.T, ctx context.Context, input map[string]any, rules []RequestRule) map[string][]string {
+ t.Helper()
+ errs, err := ValidateRequest(ctx, nil, input, rules, requestTranslator(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return errs
+}
+
+func TestValidateRequestEmptyArrayStopsAtRequired(t *testing.T) {
+ rules := []RequestRule{{Field: "posts", Rules: ParseRules("required|array|min:1")}}
+ input := map[string]any{"posts": []any{}}
+ got := mustValidate(t, inLocale("pl"), input, rules)
+ want := map[string][]string{"posts": {"Pole posts jest wymagane."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("pl = %#v, want %#v", got, want)
+ }
+ got = mustValidate(t, inLocale("en"), input, rules)
+ want = map[string][]string{"posts": {"The posts field is required."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("en = %#v, want %#v", got, want)
+ }
+}
+
+func TestValidateRequestWildcardNamesIndexedAttribute(t *testing.T) {
+ rules := []RequestRule{
+ {Field: "posts", Rules: ParseRules("required|array|min:1")},
+ {Field: "posts.*.title", Rules: ParseRules("required|string|max:255")},
+ {Field: "posts.*.tags.*", Rules: ParseRules("required")},
+ }
+ input := map[string]any{"posts": []any{
+ map[string]any{"title": "Go", "tags": []any{"a", ""}},
+ map[string]any{"tags": []any{}},
+ "not an object",
+ }}
+ got := mustValidate(t, inLocale("pl"), input, rules)
+ want := map[string][]string{
+ "posts.1.title": {"Pole posts.1.title jest wymagane."},
+ "posts.2.title": {"Pole posts.2.title jest wymagane."},
+ "posts.0.tags.1": {"Pole posts.0.tags.1 jest wymagane."},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %#v, want %#v", got, want)
+ }
+ keys := ErrorKeys(got, rules)
+ wantKeys := []string{"posts.1.title", "posts.2.title", "posts.0.tags.1"}
+ if !reflect.DeepEqual(keys, wantKeys) {
+ t.Fatalf("ErrorKeys = %v, want %v", keys, wantKeys)
+ }
+}
+
+func TestValidateRequestWildcardWithoutParentAddsNothing(t *testing.T) {
+ // Laravel drops a wildcard rule that has nothing to expand: an absent
+ // posts produces no posts.*.title attribute, so only posts reports.
+ rules := []RequestRule{
+ {Field: "posts", Rules: ParseRules("nullable|array")},
+ {Field: "posts.*.title", Rules: ParseRules("required")},
+ }
+ if got := mustValidate(t, inLocale("en"), map[string]any{}, rules); got != nil {
+ t.Fatalf("got %v, want nil", got)
+ }
+}
+
+func TestValidateRequestStringLengthCountsCharacters(t *testing.T) {
+ rules := []RequestRule{{Field: "title", Rules: ParseRules("required|string|max:255")}}
+ ok := strings.Repeat("ą", 255)
+ if got := mustValidate(t, inLocale("pl"), map[string]any{"title": ok}, rules); got != nil {
+ t.Fatalf("255 characters: %v", got)
+ }
+ got := mustValidate(t, inLocale("pl"), map[string]any{"title": ok + "ż"}, rules)
+ want := map[string][]string{"title": {"title nie może być dłuższy niż 255 znaków."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("256 characters = %#v, want %#v", got, want)
+ }
+}
+
+func TestValidateRequestBetweenIntegerBoundary(t *testing.T) {
+ rules := []RequestRule{{Field: "year", Rules: ParseRules("nullable|integer|between:1889,2100")}}
+ for _, year := range []any{float64(1889), float64(2100), "1889", nil} {
+ if got := mustValidate(t, inLocale("en"), map[string]any{"year": year}, rules); got != nil {
+ t.Fatalf("year %v: %v", year, got)
+ }
+ }
+ for _, year := range []any{float64(1888), float64(2101)} {
+ got := mustValidate(t, inLocale("en"), map[string]any{"year": year}, rules)
+ want := map[string][]string{"year": {"The year must be between 1889 and 2100."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("year %v = %#v", year, got)
+ }
+ }
+ got := mustValidate(t, inLocale("en"), map[string]any{"year": 1991.5}, rules)
+ want := map[string][]string{"year": {"The year must be an integer."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("1991.5 = %#v", got)
+ }
+}
+
+func TestValidateRequestNumericPrecision(t *testing.T) {
+ rules := []RequestRule{{Field: "market_price", Rules: ParseRules("nullable|numeric|min:0|max:999999.9999")}}
+ for _, v := range []any{"999999.9999", 999999.9999, float64(0), "0.0001"} {
+ if got := mustValidate(t, inLocale("en"), map[string]any{"market_price": v}, rules); got != nil {
+ t.Fatalf("%v: %v", v, got)
+ }
+ }
+ got := mustValidate(t, inLocale("en"), map[string]any{"market_price": float64(1000000)}, rules)
+ want := map[string][]string{"market_price": {"The market price may not be greater than 999999.9999."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("1000000 = %#v", got)
+ }
+ got = mustValidate(t, inLocale("pl"), map[string]any{"market_price": "-0.01"}, rules)
+ want = map[string][]string{"market_price": {"market price musi być nie mniejszy od 0."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("-0.01 = %#v", got)
+ }
+}
+
+func TestValidateRequestPolishFallsBackToEnglish(t *testing.T) {
+ restore := requestNow
+ requestNow = func() time.Time { return time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) }
+ t.Cleanup(func() { requestNow = restore })
+ rules := []RequestRule{{Field: "created_at", Rules: ParseRules("nullable|date|after_or_equal:1900-01-01|before_or_equal:tomorrow")}}
+ for _, v := range []string{"1900-01-01", "2026-10-03", "2026-10-03T00:00:00+00:00"} {
+ if got := mustValidate(t, inLocale("pl"), map[string]any{"created_at": v}, rules); got != nil {
+ t.Fatalf("%s: %v", v, got)
+ }
+ }
+ got := mustValidate(t, inLocale("pl"), map[string]any{"created_at": "1899-12-31"}, rules)
+ want := map[string][]string{"created_at": {"The created at must be a date after or equal to 1900-01-01."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("1899 = %#v", got)
+ }
+ got = mustValidate(t, inLocale("pl"), map[string]any{"created_at": "2026-10-04"}, rules)
+ want = map[string][]string{"created_at": {"The created at must be a date before or equal to tomorrow."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("day after tomorrow = %#v", got)
+ }
+ got = mustValidate(t, inLocale("pl"), map[string]any{"created_at": "garbage"}, rules)
+ want = map[string][]string{"created_at": {
+ "created at nie jest prawidłową datą.",
+ "The created at must be a date after or equal to 1900-01-01.",
+ }}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("garbage = %#v", got)
+ }
+}
+
+func TestValidateRequestPresenceSemantics(t *testing.T) {
+ rules := []RequestRule{
+ {Field: "name", Rules: ParseRules("sometimes|required|string|min:1|max:255")},
+ {Field: "notes", Rules: ParseRules("nullable|string")},
+ {Field: "label", Rules: ParseRules("string|max:3")},
+ {Field: "count", Rules: ParseRules("integer")},
+ {Field: "body", Rules: ParseRules("string")},
+ }
+ input := map[string]any{"notes": nil, "label": " ", "count": "", "body": nil}
+ got := mustValidate(t, inLocale("en"), input, rules)
+ want := map[string][]string{"body": {"The body must be a string."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %#v, want %#v", got, want)
+ }
+ got = mustValidate(t, inLocale("en"), map[string]any{"name": ""}, rules[:1])
+ want = map[string][]string{"name": {"The name field is required."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("blank name = %#v", got)
+ }
+}
+
+func TestValidateRequestBailAndOrder(t *testing.T) {
+ rules := []RequestRule{
+ {Field: "code", Rules: ParseRules("string|min:5|regex:/^[a-z]+$/")},
+ {Field: "slug", Rules: ParseRules("bail|string|min:5|regex:/^[a-z]+$/")},
+ }
+ got := mustValidate(t, inLocale("en"), map[string]any{"code": "A1", "slug": "A1"}, rules)
+ want := map[string][]string{
+ "code": {"The code must be at least 5 characters.", "The code format is invalid."},
+ "slug": {"The slug must be at least 5 characters."},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %#v, want %#v", got, want)
+ }
+}
+
+func TestValidateRequestCustomRuleMessageVerbatim(t *testing.T) {
+ lines := CustomRule(func(attribute string, value any) (string, bool) {
+ s, _ := value.(string)
+ if strings.Count(s, "\n")+1 > 2 {
+ return "The tracklist text may not have more than 2 lines.", true
+ }
+ return "", false
+ })
+ rules := []RequestRule{{Field: "tracklist_text", Rules: append(ParseRules("nullable|string|max:20000"), lines)}}
+ got := mustValidate(t, inLocale("pl"), map[string]any{"tracklist_text": "a\nb\nc"}, rules)
+ want := map[string][]string{"tracklist_text": {"The tracklist text may not have more than 2 lines."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %#v", got)
+ }
+ if got := mustValidate(t, inLocale("pl"), map[string]any{"tracklist_text": nil}, rules); got != nil {
+ t.Fatalf("null text: %v", got)
+ }
+}
+
+func TestValidateRequestParseRules(t *testing.T) {
+ rs := ParseRules(`nullable|string|regex:/^(a|b),c$/i|in:LP,"EP 7""",CD|max:16`)
+ var names []string
+ for _, r := range rs {
+ names = append(names, r.Name())
+ }
+ if want := []string{"nullable", "string", "regex", "in", "max"}; !reflect.DeepEqual(names, want) {
+ t.Fatalf("names = %v", names)
+ }
+ if got := rs[3].Args(); !reflect.DeepEqual(got, []string{"LP", `EP 7"`, "CD"}) {
+ t.Fatalf("in args = %q", got)
+ }
+ rules := []RequestRule{{Field: "v", Rules: rs}}
+ if got := mustValidate(t, inLocale("en"), map[string]any{"v": "B,c"}, rules); len(got["v"]) != 1 || got["v"][0] != "The selected v is invalid." {
+ t.Fatalf("got %v", got)
+ }
+ for _, bad := range []string{"required|nope", "max", "between:1", "regex:/(?<=a)b/", "exists:users;drop,id", "regex:/a/x"} {
+ func() {
+ defer func() {
+ if recover() == nil {
+ t.Fatalf("ParseRules(%q) did not panic", bad)
+ }
+ }()
+ ParseRules(bad)
+ }()
+ }
+}
+
+func TestValidateRequestUploadedFile(t *testing.T) {
+ png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
+ file := func(name string, size int64, content []byte) UploadedFile {
+ return UploadedFile{Filename: name, Size: size, Open: func() (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewReader(content)), nil
+ }}
+ }
+ rules := []RequestRule{{Field: "photo", Rules: ParseRules("required|image|mimes:jpg,jpeg,png,gif,webp|max:10240")}}
+ if got := mustValidate(t, inLocale("en"), map[string]any{"photo": file("a.png", 10240*1024, png)}, rules); got != nil {
+ t.Fatalf("10240 KB: %v", got)
+ }
+ got := mustValidate(t, inLocale("en"), map[string]any{"photo": file("a.png", 10240*1024+1, png)}, rules)
+ want := map[string][]string{"photo": {"The photo may not be greater than 10240 kilobytes."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("over limit = %#v", got)
+ }
+ got = mustValidate(t, inLocale("pl"), map[string]any{"photo": file("a.txt", 10, []byte("hello"))}, rules)
+ want = map[string][]string{"photo": {"photo musi być obrazkiem.", "photo musi być plikiem typu jpg, jpeg, png, gif, webp."}}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("text file = %#v", got)
+ }
+ got = mustValidate(t, inLocale("en"), map[string]any{"photo": file("shell.php", 10, png)}, rules)
+ if len(got["photo"]) != 2 {
+ t.Fatalf("php extension = %#v", got)
+ }
+}
+
+func TestValidateRequestEmailURLBoolean(t *testing.T) {
+ rules := []RequestRule{
+ {Field: "email", Rules: ParseRules("nullable|email")},
+ {Field: "url", Rules: ParseRules("nullable|url")},
+ {Field: "flag", Rules: ParseRules("nullable|boolean")},
+ }
+ pass := []map[string]any{
+ {"email": "jan.kowalski@example.com"},
+ {"email": "a+b@sub.example.co.uk"},
+ {"email": `"quoted"@example.com`},
+ {"email": "x@[127.0.0.1]"},
+ {"url": "https://www.discogs.com/release/1?x=1#y"},
+ {"url": "http://192.168.0.1:8080/a"},
+ {"flag": true}, {"flag": "0"}, {"flag": float64(1)},
+ }
+ for _, in := range pass {
+ if got := mustValidate(t, inLocale("en"), in, rules); got != nil {
+ t.Fatalf("%v: %v", in, got)
+ }
+ }
+ fail := []map[string]any{
+ {"email": "user@localhost"},
+ {"email": "a..b@example.com"},
+ {"email": "zażółć@example.com"},
+ {"email": "a@example.1com"},
+ {"email": strings.Repeat("a", 65) + "@example.com"},
+ {"url": "not a url"},
+ {"url": "javascript:alert(1)"},
+ {"flag": "true"}, {"flag": float64(2)},
+ }
+ for _, in := range fail {
+ if got := mustValidate(t, inLocale("en"), in, rules); got == nil {
+ t.Fatalf("%v must fail", in)
+ }
+ }
+}
+
+func TestValidateRequestExistsNeedsDatabase(t *testing.T) {
+ rules := []RequestRule{{Field: "genre_id", Rules: ParseRules("nullable|integer|exists:genres,id")}}
+ if _, err := ValidateRequest(context.Background(), nil, map[string]any{"genre_id": float64(3)}, rules, nil); err == nil {
+ t.Fatal("exists without a database handle must be an error")
+ }
+ // A failed integer rule skips exists, as Laravel does for presence rules.
+ errs, err := ValidateRequest(context.Background(), nil, map[string]any{"genre_id": "x"}, rules, nil)
+ if err != nil || len(errs["genre_id"]) != 1 {
+ t.Fatalf("errs %v err %v", errs, err)
+ }
+}
+
+func TestValidateRequestErrorKeysDeclarationOrder(t *testing.T) {
+ rules := []RequestRule{
+ {Field: "tracklist", Rules: ParseRules("nullable|array")},
+ {Field: "tracklist.*.title", Rules: ParseRules("required")},
+ {Field: "name", Rules: ParseRules("required")},
+ }
+ input := map[string]any{"tracklist": []any{
+ map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{},
+ map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{},
+ }}
+ errs := mustValidate(t, inLocale("en"), input, rules)
+ keys := ErrorKeys(errs, rules)
+ if keys[0] != "name" || keys[1] != "tracklist.0.title" || keys[2] != "tracklist.1.title" || keys[11] != "tracklist.10.title" {
+ t.Fatalf("keys = %v", keys)
+ }
+}
diff --git a/modules/lagoon/validate_rules.go b/modules/lagoon/validate_rules.go
new file mode 100644
index 0000000..3d98b67
--- /dev/null
+++ b/modules/lagoon/validate_rules.go
@@ -0,0 +1,1217 @@
+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, "