diff --git a/modules/lagoon/README.md b/modules/lagoon/README.md index 7be2b71..64bf28c 100644 --- a/modules/lagoon/README.md +++ b/modules/lagoon/README.md @@ -15,7 +15,7 @@ Postgres data layer: the shared GORM connection, per-plugin migrations, model he - One shared pool: `lagoon.Open`, `lagoon.Use` and `lagoon.OpenFromApp` return a `*sql.DB` and a `*gorm.DB` built on that same pool; `lagoon.Publish` makes both available on the `backpack.App`. - Database check at connect time: `lagoon.CheckLocale` refuses a database whose default collation is not the ICU `pl-PL` locale, so ordering matches the database default without per-query `COLLATE`. - Per-plugin migrations: `lagoon.Migrate` runs the framework's `system_files` set (`attach.Migrations`) and backend admin identity set (`lagoon.BackendAdminMigrations`), 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. `lagoon.HasFillable` and `lagoon.HasHidden` are the Go forms of `$fillable` and `$hidden`. +- 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, and a fraction or an overflow is an error. `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. - Safe ordering: `lagoon.OrderBy` appends an ORDER BY only for an allow-listed column and an `asc` or `desc` direction. - Pagination: `lagoon.Paginate` builds a `lagoon.Page` with `data` and `meta` (`current_page`, `last_page`, `per_page`, `total`). diff --git a/modules/lagoon/fill.go b/modules/lagoon/fill.go index 5cdfa83..bf403e2 100644 --- a/modules/lagoon/fill.go +++ b/modules/lagoon/fill.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "reflect" + "strconv" "strings" "sync" ) @@ -159,12 +160,49 @@ func convertValue(src reflect.Value, destType reflect.Type) (reflect.Value, erro if src.Type().AssignableTo(destType) { return src, nil } + if n, ok := src.Interface().(json.Number); ok { + if out, handled, err := convertNumber(n, destType); handled { + return out, err + } + } if src.Type().ConvertibleTo(destType) { return src.Convert(destType), nil } return reflect.Value{}, fmt.Errorf("cannot assign %s to %s", src.Type(), destType) } +// convertNumber parses a json.Number, which a request decoder using +// UseNumber produces, into an integer, unsigned or float field. A value that +// does not parse as that kind or overflows it is an error. handled is false +// for any other destination kind, so a string field still takes the number's +// text through the ordinary conversion. +func convertNumber(n json.Number, destType reflect.Type) (reflect.Value, bool, error) { + out := reflect.New(destType).Elem() + switch destType.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + i, err := strconv.ParseInt(n.String(), 10, 64) + if err != nil || out.OverflowInt(i) { + return reflect.Value{}, true, fmt.Errorf("cannot assign number %s to %s", n, destType) + } + out.SetInt(i) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + u, err := strconv.ParseUint(n.String(), 10, 64) + if err != nil || out.OverflowUint(u) { + return reflect.Value{}, true, fmt.Errorf("cannot assign number %s to %s", n, destType) + } + out.SetUint(u) + case reflect.Float32, reflect.Float64: + f, err := strconv.ParseFloat(n.String(), 64) + if err != nil || out.OverflowFloat(f) { + return reflect.Value{}, true, fmt.Errorf("cannot assign number %s to %s", n, destType) + } + out.SetFloat(f) + default: + return reflect.Value{}, false, nil + } + return out, true, nil +} + // encryptedFromRequest treats request input for an Encrypted column as // plaintext. It never falls through to Encrypted.Scan: Scan decrypts, so a // write path that scanned request input would reject real secrets and accept diff --git a/modules/lagoon/fill_test.go b/modules/lagoon/fill_test.go index 649c281..bf689a5 100644 --- a/modules/lagoon/fill_test.go +++ b/modules/lagoon/fill_test.go @@ -2,6 +2,7 @@ package lagoon import ( "bytes" + "encoding/json" "log/slog" "strings" "testing" @@ -160,3 +161,35 @@ func TestFillEncryptedTakesPlaintext(t *testing.T) { t.Fatalf("nil must clear: api_key=%q token=%v", row.APIKey.Reveal(), row.Token) } } + +// TestFillJSONNumber covers request bodies decoded with UseNumber: a +// json.Number fills integer, unsigned and float fields (plain or pointer), a +// string field keeps the number's text, and a fraction or an overflow into an +// integer field is an error rather than a silent truncation. +func TestFillJSONNumber(t *testing.T) { + type numbers struct { + Year *int `gorm:"column:year"` + Count uint8 `gorm:"column:count"` + Price float64 `gorm:"column:price"` + Code string `gorm:"column:code"` + } + allowed := []string{"year", "count", "price", "code"} + var row numbers + err := Fill(&row, allowed, map[string]any{ + "year": json.Number("1977"), + "count": json.Number("200"), + "price": json.Number("12.5"), + "code": json.Number("0042"), + }, true) + if err != nil { + t.Fatal(err) + } + if row.Year == nil || *row.Year != 1977 || row.Count != 200 || row.Price != 12.5 || row.Code != "0042" { + t.Fatalf("row = %+v (year %v)", row, row.Year) + } + for key, value := range map[string]json.Number{"year": "1977.5", "count": "256"} { + if err := Fill(&row, allowed, map[string]any{key: value}, true); err == nil { + t.Fatalf("%s = %s filled without an error", key, value) + } + } +}