From f9b7f2ea337e61f2a92b5b504dd1c6d59705332b Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 11:25:36 +0200 Subject: [PATCH] feat(12-01): add Laravel request validation to lagoon - lagoon.ValidateRequest ports Laravel 9 request validation: wildcard expansion, implicit-rule stop, bail, sometimes/nullable/blank skipping, size messages split by type and character-counted string lengths - ParseRules, In, CustomRule, UploadedFile and ErrorKeys for rule tables - pl/en lagoon::validation catalogs ported verbatim from WinterCMS - lagoon.Validate answers a numeric range failure with the bound that failed (min, max or numeric between) instead of always max --- docs/database/casts-and-validation.md | 27 +- docs/services/localization.md | 2 +- modules/lagoon/README.md | 11 +- modules/lagoon/validate.go | 68 +- modules/lagoon/validate_request.go | 732 ++++++++++++ modules/lagoon/validate_request_test.go | 365 ++++++ modules/lagoon/validate_rules.go | 1217 ++++++++++++++++++++ modules/lagoon/validate_test.go | 40 + modules/phrasebook/README.md | 2 +- modules/phrasebook/lang/en/validation.yaml | 102 ++ modules/phrasebook/lang/pl/validation.yaml | 69 ++ 11 files changed, 2598 insertions(+), 37 deletions(-) create mode 100644 modules/lagoon/validate_request.go create mode 100644 modules/lagoon/validate_request_test.go create mode 100644 modules/lagoon/validate_rules.go create mode 100644 modules/phrasebook/lang/en/validation.yaml create mode 100644 modules/phrasebook/lang/pl/validation.yaml 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, " *b: + c = 1 + } + default: + ab := a != nil && *a != 0 + bb := b != nil && *b != 0 + switch { + case !ab && bb: + c = -1 + case ab && !bb: + c = 1 + } + } + switch op { + case ">": + return c > 0 + case ">=": + return c >= 0 + case "<": + return c < 0 + default: + return c <= 0 + } +} + +// requestNow is the clock for relative date words; tests replace it. +var requestNow = time.Now + +// parseDateArg parses a date rule parameter or value the way Carbon::parse +// does for the shapes parseDateValue accepts, plus the relative words today, +// tomorrow, yesterday and now (midnight or the current time, in UTC). +func parseDateArg(s string) (time.Time, bool) { + now := requestNow().UTC() + midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + switch strings.ToLower(strings.TrimSpace(s)) { + case "now": + return now, true + case "today", "midnight": + return midnight, true + case "tomorrow": + return midnight.AddDate(0, 0, 1), true + case "yesterday": + return midnight.AddDate(0, 0, -1), true + } + return parseDateValue(s) +} + +var ( + dateISO = regexp.MustCompile(`^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?$`) + dateSlash = regexp.MustCompile(`^(\d{4})/(\d{1,2})/(\d{1,2})$`) + dateUS = regexp.MustCompile(`^(\d{1,2})/(\d{1,2})/(\d{4})$`) + dateDot = regexp.MustCompile(`^(\d{1,2})[.-](\d{1,2})[.-](\d{4})$`) +) + +// parseDateValue accepts the date shapes the API clients send, all read in +// UTC unless an offset is given: Y-m-d, Y-m-d H:i[:s[.u]] and the ISO 8601 +// form with a T, Z or an offset; Y/m/d; m/d/Y (strtotime's American slash +// order); d.m.Y and d-m-Y. The calendar date must exist (checkdate), so +// 2023-02-30 is refused. Other strtotime inputs are refused. +func parseDateValue(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + var y, mo, d, h, mi, sec, nsec int + loc := time.UTC + switch { + case dateISO.MatchString(s): + m := dateISO.FindStringSubmatch(s) + y, mo, d = atoi(m[1]), atoi(m[2]), atoi(m[3]) + if m[4] != "" { + h, mi = atoi(m[4]), atoi(m[5]) + } + if m[6] != "" { + sec = atoi(m[6]) + } + if m[7] != "" { + frac := (m[7] + "000000000")[:9] + nsec = atoi(frac) + } + if z := m[8]; z != "" && z != "Z" { + sign := 1 + if z[0] == '-' { + sign = -1 + } + digits := strings.ReplaceAll(z[1:], ":", "") + oh := atoi(digits[:2]) + om := 0 + if len(digits) == 4 { + om = atoi(digits[2:]) + } + if oh > 23 || om > 59 { + return time.Time{}, false + } + loc = time.FixedZone("", sign*(oh*3600+om*60)) + } + case dateSlash.MatchString(s): + m := dateSlash.FindStringSubmatch(s) + y, mo, d = atoi(m[1]), atoi(m[2]), atoi(m[3]) + case dateUS.MatchString(s): + m := dateUS.FindStringSubmatch(s) + mo, d, y = atoi(m[1]), atoi(m[2]), atoi(m[3]) + case dateDot.MatchString(s): + m := dateDot.FindStringSubmatch(s) + d, mo, y = atoi(m[1]), atoi(m[2]), atoi(m[3]) + default: + return time.Time{}, false + } + if !checkDate(y, mo, d) || h > 23 || mi > 59 || sec > 59 { + return time.Time{}, false + } + return time.Date(y, time.Month(mo), d, h, mi, sec, nsec, loc), true +} + +func atoi(s string) int { + n, _ := strconv.Atoi(s) + return n +} + +func checkDate(y, m, d int) bool { + if y < 1 || y > 32767 || m < 1 || m > 12 || d < 1 { + return false + } + return d <= time.Date(y, time.Month(m)+1, 0, 0, 0, 0, 0, time.UTC).Day() +} + +// filterEmail ports PHP's FILTER_VALIDATE_EMAIL, the check Winter's email +// rule uses by default: ASCII only, under 255 characters, a local part of +// dot-separated atoms or quoted strings up to 64 characters, and a domain of +// at least two dot-separated labels whose last starts with a letter (or is +// an xn-- label), or a bracketed IPv4 or IPv6 literal. +func filterEmail(s string) bool { + if s == "" || len(s) >= 255 { + return false + } + for i := 0; i < len(s); i++ { + if s[i] >= 0x80 { + return false + } + } + at := emailLocalEnd(s) + if at <= 0 || at >= len(s) || s[at] != '@' || at > 64 { + return false + } + return emailDomainOK(s[at+1:]) +} + +func isAtext(c byte) bool { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + return true + } + return strings.IndexByte("!#$%&'*+-/=?^_`{|}~", c) >= 0 +} + +// emailLocalEnd parses the local part and returns the index of the @ that +// ends it, or -1. +func emailLocalEnd(s string) int { + i := 0 + for { + if i >= len(s) { + return -1 + } + if s[i] == '"' { + i++ + for { + if i >= len(s) { + return -1 + } + c := s[i] + if c == '"' { + i++ + break + } + if c == '\\' { + if i+1 >= len(s) || s[i+1] > 0x7F { + return -1 + } + i += 2 + continue + } + if c == 0 || c == '\t' || c == '\n' || c == '\r' || c == ' ' || c > 0x7F { + return -1 + } + i++ + } + } else { + start := i + for i < len(s) && isAtext(s[i]) { + i++ + } + if i == start { + return -1 + } + } + if i < len(s) && s[i] == '.' { + i++ + continue + } + if i < len(s) && s[i] == '@' { + return i + } + return -1 + } +} + +var ( + emailLabel = regexp.MustCompile(`(?i)^(?:xn--)?[a-z0-9]+(?:-+[a-z0-9]+)*$`) + emailTopLabel = regexp.MustCompile(`(?i)^(?:[a-z][a-z0-9]*|xn--[a-z0-9]+)(?:-+[a-z0-9]+)*$`) + emailIPv4 = regexp.MustCompile(`^(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$`) +) + +func emailDomainOK(d string) bool { + if strings.HasPrefix(d, "[") && strings.HasSuffix(d, "]") { + lit := d[1 : len(d)-1] + if len(lit) > 5 && strings.EqualFold(lit[:5], "IPv6:") { + ip := net.ParseIP(lit[5:]) + return ip != nil && strings.Contains(lit[5:], ":") + } + return emailIPv4.MatchString(lit) + } + labels := strings.Split(d, ".") + if len(labels) < 2 || len(labels) > 127 { + return false + } + for i, l := range labels { + if len(l) >= 64 { + return false + } + if i == len(labels)-1 { + if !emailTopLabel.MatchString(l) { + return false + } + continue + } + if !emailLabel.MatchString(l) { + return false + } + } + return true +} + +// urlPattern is Laravel 9's validateUrl pattern (derived from Symfony's +// UrlValidator) rewritten for RE2: the same protocols, optional basic auth, +// a domain name, IPv4 or bracketed IPv6 host, optional port, path, query +// and fragment, matched case-insensitively. +var urlPattern = regexp.MustCompile(`(?i)^` + + `(aaa|aaas|about|acap|acct|acd|acr|adiumxtra|adt|afp|afs|aim|amss|android|appdata|apt|ark|attachment|aw|barion|beshare|bitcoin|bitcoincash|blob|bolo|browserext|calculator|callto|cap|cast|casts|chrome|chrome-extension|cid|coap|coap\+tcp|coap\+ws|coaps|coaps\+tcp|coaps\+ws|com-eventbrite-attendee|content|conti|crid|cvs|dab|data|dav|diaspora|dict|did|dis|dlna-playcontainer|dlna-playsingle|dns|dntp|dpp|drm|drop|dtn|dvb|ed2k|elsi|example|facetime|fax|feed|feedready|file|filesystem|finger|first-run-pen-experience|fish|fm|ftp|fuchsia-pkg|geo|gg|git|gizmoproject|go|gopher|graph|gtalk|h323|ham|hcap|hcp|http|https|hxxp|hxxps|hydrazone|iax|icap|icon|im|imap|info|iotdisco|ipn|ipp|ipps|irc|irc6|ircs|iris|iris\.beep|iris\.lwz|iris\.xpc|iris\.xpcs|isostore|itms|jabber|jar|jms|keyparc|lastfm|ldap|ldaps|leaptofrogans|lorawan|lvlt|magnet|mailserver|mailto|maps|market|message|mid|mms|modem|mongodb|moz|ms-access|ms-browser-extension|ms-calculator|ms-drive-to|ms-enrollment|ms-excel|ms-eyecontrolspeech|ms-gamebarservices|ms-gamingoverlay|ms-getoffice|ms-help|ms-infopath|ms-inputapp|ms-lockscreencomponent-config|ms-media-stream-id|ms-mixedrealitycapture|ms-mobileplans|ms-officeapp|ms-people|ms-project|ms-powerpoint|ms-publisher|ms-restoretabcompanion|ms-screenclip|ms-screensketch|ms-search|ms-search-repair|ms-secondary-screen-controller|ms-secondary-screen-setup|ms-settings|ms-settings-airplanemode|ms-settings-bluetooth|ms-settings-camera|ms-settings-cellular|ms-settings-cloudstorage|ms-settings-connectabledevices|ms-settings-displays-topology|ms-settings-emailandaccounts|ms-settings-language|ms-settings-location|ms-settings-lock|ms-settings-nfctransactions|ms-settings-notifications|ms-settings-power|ms-settings-privacy|ms-settings-proximity|ms-settings-screenrotation|ms-settings-wifi|ms-settings-workplace|ms-spd|ms-sttoverlay|ms-transit-to|ms-useractivityset|ms-virtualtouchpad|ms-visio|ms-walk-to|ms-whiteboard|ms-whiteboard-cmd|ms-word|msnim|msrp|msrps|mss|mtqp|mumble|mupdate|mvn|news|nfs|ni|nih|nntp|notes|ocf|oid|onenote|onenote-cmd|opaquelocktoken|openpgp4fpr|pack|palm|paparazzi|payto|pkcs11|platform|pop|pres|prospero|proxy|pwid|psyc|pttp|qb|query|redis|rediss|reload|res|resource|rmi|rsync|rtmfp|rtmp|rtsp|rtsps|rtspu|s3|secondlife|service|session|sftp|sgn|shttp|sieve|simpleledger|sip|sips|skype|smb|sms|smtp|snews|snmp|soap\.beep|soap\.beeps|soldat|spiffe|spotify|ssh|steam|stun|stuns|submit|svn|tag|teamspeak|tel|teliaeid|telnet|tftp|tg|things|thismessage|tip|tn3270|tool|ts3server|turn|turns|tv|udp|unreal|urn|ut2004|v-event|vemmi|ventrilo|videotex|vnc|view-source|wais|webcal|wpid|ws|wss|wtai|wyciwyg|xcon|xcon-userid|xfire|xmlrpc\.beep|xmlrpc\.beeps|xmpp|xri|ymsgr|z39\.50|z39\.50r|z39\.50s)://` + + `(((?:[_.\pL\pN-]|%[0-9A-Fa-f]{2})+:)?((?:[_.\pL\pN-]|%[0-9A-Fa-f]{2})+)@)?` + + `(` + + `([\pL\pN\pS\-_.])+(\.?([\pL\pN]|xn--[\pL\pN-]+)+\.?)` + + `|` + + `\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}` + + `|` + + `\[` + urlIPv6 + `\]` + + `)` + + `(:[0-9]+)?` + + `(?:/(?:[\pL\pN\-._~!$&'()*+,;=:@]|%[0-9A-Fa-f]{2})*)*` + + `(?:\?(?:[\pL\pN\-._~!$&'\[\]()*+,;=:@/?]|%[0-9A-Fa-f]{2})*)?` + + `(?:#(?:[\pL\pN\-._~!$&'()*+,;=:@/?]|%[0-9A-Fa-f]{2})*)?` + + `$`) + +const urlIPv6 = `(?:(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-f]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,1}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,2}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,3}(?:(?:[0-9a-f]{1,4})))?::(?:(?:[0-9a-f]{1,4})):)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,4}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,5}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,6}(?:(?:[0-9a-f]{1,4})))?::))))` diff --git a/modules/lagoon/validate_test.go b/modules/lagoon/validate_test.go index 2bff632..e632c51 100644 --- a/modules/lagoon/validate_test.go +++ b/modules/lagoon/validate_test.go @@ -229,3 +229,43 @@ func TestValidateUniqueRespectsDeletedAt(t *testing.T) { t.Fatalf("soft-deleted slug should pass unique: %v", errs) } } + +func TestValidateNumericRangeMessagePicksFailedBound(t *testing.T) { + cases := []struct { + rule string + val any + want string + }{ + {"integer|min:0", -1, "The views must be at least 0."}, + {"integer|max:10", 11, "The views may not be greater than 10."}, + {"numeric|min:0|max:10", "-0.5", "The views must be at least 0."}, + {"numeric|min:0|max:10", "10.5", "The views may not be greater than 10."}, + {"integer|between:0,10", 11, "The views must be between 0 and 10."}, + {"integer|between:0,10", -1, "The views must be between 0 and 10."}, + {"integer|between:0,10|min:2", 1, "The views must be at least 2."}, + } + for _, c := range cases { + errs, err := Validate(t.Context(), nil, nil, map[string]string{"views": c.rule}, map[string]any{"views": c.val}, nil) + if err != nil { + t.Fatal(err) + } + if got := errs["views"]; len(got) != 1 || got[0] != c.want { + t.Fatalf("%s with %v = %v, want %q", c.rule, c.val, got, c.want) + } + } + errs, err := Validate(t.Context(), nil, nil, map[string]string{"views": "integer|between:0,10"}, map[string]any{"views": 5}, nil) + if err != nil || errs != nil { + t.Fatalf("in range: %v %v", errs, err) + } +} + +func TestValidateNumericRangeMessageTranslated(t *testing.T) { + tr := requestTranslator(t) + errs, err := Validate(inLocale("pl"), nil, nil, map[string]string{"year": "integer|between:1889,2100"}, map[string]any{"year": 1700}, tr) + if err != nil { + t.Fatal(err) + } + if got := errs["year"]; len(got) != 1 || got[0] != "year musi zawierać się w granicach 1889 - 2100." { + t.Fatalf("pl between = %v", got) + } +} diff --git a/modules/phrasebook/README.md b/modules/phrasebook/README.md index 7c80d32..b1183aa 100644 --- a/modules/phrasebook/README.md +++ b/modules/phrasebook/README.md @@ -12,7 +12,7 @@ phrasebook owns every translatable string in a SummerCMS application. At boot, ` - YAML catalogs laid out as `lang//.yaml`; nested maps flatten into keys such as `acme.blog::posts.title` (`phrasebook.Catalog.Load`). Duplicate keys, duplicate namespaces, malformed paths and non-string leaves fail at load time. - Overrides laid out as `lang///.yaml` that replace keys of any loaded namespace, including the framework admin strings, and may add locales (`phrasebook.Catalog.Override`). -- Built-in framework namespaces: `lagoon::validate.*` (validation messages) and `backend::lang.*` (admin UI strings), shipped for `en` and `pl`. +- Built-in framework namespaces: `lagoon::validate.*` (the messages of `lagoon.Validate`), `lagoon::validation.*` (the WinterCMS/Laravel validator catalog used by `lagoon.ValidateRequest`, with the size messages split into `numeric`, `file`, `string` and `array`; a key the Polish file lacks, such as `after_or_equal`, falls back to English as in WinterCMS) and `backend::lang.*` (admin UI strings), shipped for `en` and `pl`. - Lookups by request locale (`phrasebook.Translator.Get`, read from the context through `towel.Locale`) or by explicit locale (`phrasebook.Translator.GetIn`), walking the locale, its parent tags (`pt-BR` to `pt`) and then the fallback locale. A missing key returns the key itself. - Laravel-style placeholders: `:name`, `:Name` (first letter upper-cased) and `:NAME` (upper-cased). - Pluralization with `phrasebook.Translator.Choice` and `phrasebook.Translator.ChoiceIn`: CLDR plural maps (`one`, `few`, `many`, `other`, ...) validated against the locale's categories, or Laravel pipes with exact (`{0}`) and range (`[2,*]`) conditions. `:count` is filled in automatically. diff --git a/modules/phrasebook/lang/en/validation.yaml b/modules/phrasebook/lang/en/validation.yaml new file mode 100644 index 0000000..5db7725 --- /dev/null +++ b/modules/phrasebook/lang/en/validation.yaml @@ -0,0 +1,102 @@ +# Port of WinterCMS modules/system/lang/en/validation.php (Laravel validator +# messages). Text is copied verbatim; keys absent from this locale fall back to +# app.fallback_locale as in Winter. Loaded as lagoon::validation.* +accepted: "The :attribute must be accepted." +active_url: "The :attribute is not a valid URL." +after: "The :attribute must be a date after :date." +after_or_equal: "The :attribute must be a date after or equal to :date." +alpha: "The :attribute may only contain letters." +alpha_dash: "The :attribute may only contain letters, numbers, and dashes." +alpha_num: "The :attribute may only contain letters and numbers." +array: "The :attribute must be an array." +before: "The :attribute must be a date before :date." +before_or_equal: "The :attribute must be a date before or equal to :date." +between: + numeric: "The :attribute must be between :min and :max." + file: "The :attribute must be between :min and :max kilobytes." + string: "The :attribute must be between :min and :max characters." + array: "The :attribute must have between :min and :max items." +boolean: "The :attribute field must be true or false." +confirmed: "The :attribute confirmation does not match." +date: "The :attribute is not a valid date." +date_equals: "The :attribute must be a date equal to :date." +date_format: "The :attribute does not match the format :format." +different: "The :attribute and :other must be different." +digits: "The :attribute must be :digits digits." +digits_between: "The :attribute must be between :min and :max digits." +dimensions: "The :attribute has invalid image dimensions." +distinct: "The :attribute field has a duplicate value." +email: "The :attribute must be a valid email address." +ends_with: "The :attribute must end with one of the following: :values." +exists: "The selected :attribute is invalid." +file: "The :attribute must be a file." +filled: "The :attribute field must have a value." +gt: + numeric: "The :attribute must be greater than :value." + file: "The :attribute must be greater than :value kilobytes." + string: "The :attribute must be greater than :value characters." + array: "The :attribute must have more than :value items." +gte: + numeric: "The :attribute must be greater than or equal :value." + file: "The :attribute must be greater than or equal :value kilobytes." + string: "The :attribute must be greater than or equal :value characters." + array: "The :attribute must have :value items or more." +image: "The :attribute must be an image." +in: "The selected :attribute is invalid." +in_array: "The :attribute field does not exist in :other." +integer: "The :attribute must be an integer." +ip: "The :attribute must be a valid IP address." +ipv4: "The :attribute must be a valid IPv4 address." +ipv6: "The :attribute must be a valid IPv6 address." +json: "The :attribute must be a valid JSON string." +lt: + numeric: "The :attribute must be less than :value." + file: "The :attribute must be less than :value kilobytes." + string: "The :attribute must be less than :value characters." + array: "The :attribute must have less than :value items." +lte: + numeric: "The :attribute must be less than or equal :value." + file: "The :attribute must be less than or equal :value kilobytes." + string: "The :attribute must be less than or equal :value characters." + array: "The :attribute must not have more than :value items." +max: + numeric: "The :attribute may not be greater than :max." + file: "The :attribute may not be greater than :max kilobytes." + string: "The :attribute may not be greater than :max characters." + array: "The :attribute may not have more than :max items." +mimes: "The :attribute must be a file of type: :values." +mimetypes: "The :attribute must be a file of type: :values." +min: + numeric: "The :attribute must be at least :min." + file: "The :attribute must be at least :min kilobytes." + string: "The :attribute must be at least :min characters." + array: "The :attribute must have at least :min items." +not_in: "The selected :attribute is invalid." +not_regex: "The :attribute format is invalid." +numeric: "The :attribute must be a number." +present: "The :attribute field must be present." +regex: "The :attribute format is invalid." +required: "The :attribute field is required." +required_if: "The :attribute field is required when :other is :value." +required_unless: "The :attribute field is required unless :other is in :values." +required_with: "The :attribute field is required when :values is present." +required_with_all: "The :attribute field is required when :values is present." +required_without: "The :attribute field is required when :values is not present." +required_without_all: "The :attribute field is required when none of :values are present." +same: "The :attribute and :other must match." +size: + numeric: "The :attribute must be :size." + file: "The :attribute must be :size kilobytes." + string: "The :attribute must be :size characters." + array: "The :attribute must contain :size items." +starts_with: "The :attribute must start with one of the following: :values." +string: "The :attribute must be a string." +timezone: "The :attribute must be a valid zone." +unique: "The :attribute has already been taken." +uploaded: "The :attribute failed to upload." +url: "The :attribute format is invalid." +uuid: "The :attribute must be a valid UUID." +custom: + attribute-name: + rule-name: "custom-message" +attributes: {} diff --git a/modules/phrasebook/lang/pl/validation.yaml b/modules/phrasebook/lang/pl/validation.yaml new file mode 100644 index 0000000..8f6a138 --- /dev/null +++ b/modules/phrasebook/lang/pl/validation.yaml @@ -0,0 +1,69 @@ +# Port of WinterCMS modules/system/lang/pl/validation.php (Laravel validator +# messages). Text is copied verbatim; keys absent from this locale fall back to +# app.fallback_locale as in Winter. Loaded as lagoon::validation.* +accepted: ":attribute musi zostać zaakceptowany." +active_url: ":attribute jest nieprawidłowym adresem URL." +after: ":attribute musi być datą późniejszą od :date." +alpha: ":attribute może zawierać jedynie litery." +alpha_dash: ":attribute może zawierać jedynie litery, cyfry i myślniki." +alpha_num: ":attribute może zawierać jedynie litery i cyfry." +array: ":attribute musi być tablicą." +before: ":attribute musi być datą wcześniejszą od :date." +between: + numeric: ":attribute musi zawierać się w granicach :min - :max." + file: ":attribute musi zawierać się w granicach :min - :max kilobajtów." + string: ":attribute musi zawierać się w granicach :min - :max znaków." + array: ":attribute musi składać się z :min - :max elementów." +boolean: ":attribute musi mieć wartość prawda albo fałsz" +confirmed: "Potwierdzenie :attribute nie zgadza się." +date: ":attribute nie jest prawidłową datą." +date_format: ":attribute nie jest w formacie :format." +different: ":attribute oraz :other muszą się różnić." +digits: ":attribute musi składać się z :digits cyfr." +digits_between: ":attribute musi mieć od :min do :max cyfr." +dimensions: "The :attribute has invalid image dimensions." +distinct: "The :attribute field has a duplicate value." +email: "Format :attribute jest nieprawidłowy." +exists: "Zaznaczony :attribute jest nieprawidłowy." +file: "The :attribute must be a file." +filled: "Pole :attribute jest wymagane." +image: ":attribute musi być obrazkiem." +in: "Zaznaczony :attribute jest nieprawidłowy." +in_array: "The :attribute field does not exist in :other." +integer: ":attribute musi być liczbą całkowitą." +ip: ":attribute musi być prawidłowym adresem IP." +json: "The :attribute must be a valid JSON string." +max: + numeric: ":attribute nie może być większy niż :max." + file: ":attribute nie może być większy niż :max kilobajtów." + string: ":attribute nie może być dłuższy niż :max znaków." + array: ":attribute nie może mieć więcej niż :max elementów." +mimes: ":attribute musi być plikiem typu :values." +min: + numeric: ":attribute musi być nie mniejszy od :min." + file: ":attribute musi mieć przynajmniej :min kilobajtów." + string: ":attribute musi mieć przynajmniej :min znaków." + array: ":attribute musi mieć przynajmniej :min elementów." +not_in: "Zaznaczony :attribute jest nieprawidłowy." +numeric: ":attribute musi być liczbą." +present: "The :attribute field must be present." +regex: "Format :attribute jest nieprawidłowy." +required: "Pole :attribute jest wymagane." +required_if: "Pole :attribute jest wymagane gdy :other jest :value." +required_unless: "The :attribute field is required unless :other is in :values." +required_with: "Pole :attribute jest wymagane gdy :values jest obecny." +required_with_all: "Pole :attribute jest wymagane gdy :values jest obecny." +required_without: "Pole :attribute jest wymagane gdy :values nie jest obecny." +required_without_all: "Pole :attribute jest wymagane gdy żadne z :values nie są obecne." +same: "Pole :attribute i :other muszą się zgadzać." +size: + numeric: ":attribute musi mieć :size." + file: ":attribute musi mieć :size kilobajtów." + string: ":attribute musi mieć :size znaków." + array: ":attribute musi zawierać :size elementów." +string: "The :attribute must be a string." +timezone: ":attribute musi być prawidłową strefą czasową." +unique: "Taki :attribute już występuje." +url: "Format :attribute jest nieprawidłowy." +custom: {} +attributes: {}