feat(lagoon): check min and max as character length on string and url fields (quick-261104-f4x)

- on a string or url field that is not numeric or integer, min and max
  compare utf8.RuneCountInString (Laravel's mb_strlen) and answer with
  the lagoon::validation min.string / max.string messages
- a min or max argument that is not a whole number is the
  unrecognized-rule error, whatever the value
- rule strings without string or url keep their results (pinned by a
  test); between is unchanged
This commit is contained in:
Jakub Zych
2026-11-04 11:13:09 +01:00
parent d2ce3a2c3b
commit d166f1c308
5 changed files with 109 additions and 4 deletions

View File

@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/go-playground/validator/v10"
@@ -118,6 +119,20 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
return nil, fmt.Errorf("lagoon: unrecognized validation rule %q", tok)
}
}
// On a string or url field min and max are a length in characters. A
// malformed bound fails loudly whatever the value, so a typo in Rules()
// cannot disable the limit unnoticed.
lengthRange := (hasString || hasURL) && !hasNumeric && !hasInteger
minLen, maxLen := -1, -1
if lengthRange {
var err error
if minLen, err = lengthBound("min", minArg); err != nil {
return nil, err
}
if maxLen, err = lengthBound("max", maxArg); err != nil {
return nil, err
}
}
if nullable && isEmptyValue(val) && !required {
return nil, nil
}
@@ -173,6 +188,16 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
if hasURL && (!isStr || !urlPattern.MatchString(s)) {
msgs = append(msgs, validateMessage(ctx, tr, "url", field, nil))
}
if lengthRange && isStr {
n := utf8.RuneCountInString(s)
params := map[string]string{"min": minArg, "max": maxArg}
if minLen >= 0 && n < minLen {
msgs = append(msgs, validateMessage(ctx, tr, "min.string", field, params))
}
if maxLen >= 0 && n > maxLen {
msgs = append(msgs, validateMessage(ctx, tr, "max.string", field, params))
}
}
if len(msgs) > 0 {
return msgs, nil
}
@@ -350,6 +375,19 @@ func isIntegerValue(val any) bool {
}
}
// lengthBound parses a min or max argument used as a character length; an
// absent one is -1 and a malformed one the unrecognized-rule error.
func lengthBound(name, arg string) (int, error) {
if arg == "" {
return -1, nil
}
n, err := strconv.Atoi(strings.TrimSpace(arg))
if err != nil || n < 0 {
return -1, fmt.Errorf("lagoon: unrecognized validation rule %q", name+":"+arg)
}
return n, nil
}
// ruleString answers the string a string or url rule checks: it
// dereferences pointers and interfaces (a nil one is no string) and accepts
// any value whose kind is string, named string types included.
@@ -470,8 +508,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"
switch rule {
case "between.numeric", "min.string", "max.string":
key = "lagoon::validation." + rule
}
if tr != nil {
s := tr.Get(ctx, key, params)
@@ -513,6 +552,10 @@ func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field
return "The " + attr + " must be a string."
case "url":
return "The " + attr + " format is invalid."
case "min.string":
return "The " + attr + " must be at least " + params["min"] + " characters."
case "max.string":
return "The " + attr + " may not be greater than " + params["max"] + " characters."
default:
return "The " + attr + " is invalid."
}