fix(12.2): close code-review blockers on uploads, JSON caps, and pivot fill

Keep form save behind in-flight uploads, make retries idempotent via X-Upload-Id, cap remaining JSON bodies, and surface pending pivot type errors instead of zeroing them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-02 23:10:48 +02:00
parent 6bfc0faa8a
commit 516f9c9025
22 changed files with 533 additions and 96 deletions

View File

@@ -19,7 +19,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns, and a struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` above `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
@@ -210,8 +210,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `backend.uri` | `/backend` | Admin mount path. One or more lowercase path segments; boot fails on an invalid value. |
| `backend.cookie_secure` | `true` | Set `false` to drop the cookie's Secure attribute for plain-HTTP development. Refused in the `production` environment. |
| `app.url` | empty | Base URL used for the token issuer. |
| `http.body_limits.upload_bytes` | none | Read from the HTTP configuration: caps the body of a file upload (together with the field's `maxFilesize` plus 64 KiB), and no fileupload field may declare a larger `maxFilesize`. Without it the cap is the field's limit, or 128 MiB. |
| `http.body_limits.default_bytes` | none | Read from the HTTP configuration: caps the JSON bodies of the file caption and reorder routes and of the relation child routes (1 MiB when not set; 413 `payload_too_large` past it). |
| `http.body_limits.upload_bytes` | none | Read from the HTTP configuration: caps the body of a file upload (together with the field's `maxFilesize` plus 64 KiB), and no fileupload field may declare a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds it. Without it the cap is the field's limit, or 128 MiB. |
| `http.body_limits.default_bytes` | none | Read from the HTTP configuration: caps the JSON bodies of create, update, settings, relation link/unlink, bulk delete, file caption and reorder, and relation child routes (1 MiB when not set; 413 `payload_too_large` past it). |
The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs.

View File

@@ -410,6 +410,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
}
func writeCRUDError(w http.ResponseWriter, err error) {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge)
return
}
var ve *ValidationError
if errors.As(err, &ve) {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", ve.Details)

View File

@@ -364,20 +364,21 @@ func compileFileFields(pluginID string, cc *CompiledController) error {
return nil
}
// checkFileLimits refuses a maxFilesize above http.body_limits.upload_bytes,
// as WinterCMS refuses one above upload_max_filesize.
// checkFileLimits refuses a maxFilesize whose file plus multipart framing
// cannot fit in http.body_limits.upload_bytes. Equality is not enough:
// the request cap is the whole multipart body.
func checkFileLimits(reg *Registry, uploadBytes int64) error {
if reg == nil || uploadBytes <= 0 {
return nil
}
for _, cc := range reg.byID {
for _, cf := range cc.files {
if cf.maxBytes > uploadBytes {
if cf.maxBytes > 0 && cf.maxBytes+multipartOverhead > uploadBytes {
path := ""
if cc.Form != nil {
path = cc.Form.fieldsPath
}
return bootErr(cc.PluginID, controllerID(cc), path, fmt.Errorf("field %s: maxFilesize exceeds http.body_limits.upload_bytes", cf.name))
return bootErr(cc.PluginID, controllerID(cc), path, fmt.Errorf("field %s: maxFilesize plus multipart overhead exceeds http.body_limits.upload_bytes", cf.name))
}
}
}
@@ -510,6 +511,10 @@ func parentFileScope(ctx context.Context, tx *gorm.DB, r *http.Request, cc *Comp
// attached to the owner minus the session's pending removals, plus the
// session's pending uploads, in sort_order then id order.
func (sc *fileScope) visibleFiles(tx *gorm.DB) ([]attach.File, map[uint]bool, error) {
return sc.visibleFilesLocked(tx, false)
}
func (sc *fileScope) visibleFilesLocked(tx *gorm.DB, lock bool) ([]attach.File, map[uint]bool, error) {
q := tx.Session(&gorm.Session{NewDB: true}).Model(&attach.File{})
var attached *gorm.DB
if sc.ownerID > 0 {
@@ -534,6 +539,9 @@ func (sc *fileScope) visibleFiles(tx *gorm.DB) ([]attach.File, map[uint]bool, er
default:
return nil, nil, nil
}
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
var files []attach.File
if err := q.Order("sort_order").Order("id").Find(&files).Error; err != nil {
return nil, nil, err
@@ -547,6 +555,60 @@ func (sc *fileScope) visibleFiles(tx *gorm.DB) ([]attach.File, map[uint]bool, er
return files, isPending, nil
}
const uploadIDHeader = "X-Upload-Id"
const maxUploadIDLen = 64
func parseUploadID(r *http.Request) string {
raw := strings.TrimSpace(r.Header.Get(uploadIDHeader))
if raw == "" || len(raw) > maxUploadIDLen {
return ""
}
for _, c := range raw {
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '-' && c != '_' {
return ""
}
}
return raw
}
// fileForUploadID returns the pending file already stored for this
// session, field and client upload id, or nil when none exists.
func (sc *fileScope) fileForUploadID(ctx context.Context, tx *gorm.DB, uploadID string) (*attach.File, error) {
if uploadID == "" || !sc.hasKey {
return nil, nil
}
rows, err := lagoon.DeferredBindings(ctx, tx, sc.key, []string{sc.file.name})
if err != nil {
return nil, err
}
for _, row := range rows {
if !row.IsBind || row.SlaveType != lagoon.DeferredFileType {
continue
}
env, err := row.Envelope()
if err != nil {
return nil, err
}
if env.UploadID != uploadID {
continue
}
id, err := strconv.ParseUint(row.SlaveID, 10, 64)
if err != nil {
return nil, err
}
var f attach.File
err = tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id = ?", uint(id)).Take(&f).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &f, nil
}
return nil, nil
}
// fileItem projects a stored file. Public URLs are emitted only for a
// public relation (never for a protected file, D-10).
func fileItem(ctx context.Context, bucket *blob.Bucket, cf *compiledFile, f *attach.File, pending bool) FileItem {
@@ -643,6 +705,7 @@ func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *Compi
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
uploadID := parseUploadID(r)
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))}
r.Body = io.NopCloser(body)
mr, err := r.MultipartReader()
@@ -667,6 +730,12 @@ func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *Compi
if err != nil {
return err
}
if existing, err := sc.fileForUploadID(ctx, tx, uploadID); err != nil {
return lifecycleFailure(cc, err)
} else if existing != nil {
item = fileItem(ctx, bucket, cf, existing, true)
return nil
}
if cf.relation.Many && cf.maxFiles > 0 {
files, _, err := sc.visibleFiles(tx)
if err != nil {
@@ -688,7 +757,11 @@ func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *Compi
}
return err
}
if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), nil); err != nil {
var env *lagoon.DeferredEnvelope
if uploadID != "" {
env = &lagoon.DeferredEnvelope{UploadID: uploadID}
}
if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), env); err != nil {
return lifecycleFailure(cc, err)
}
item = fileItem(ctx, bucket, cf, f, true)
@@ -1245,7 +1318,7 @@ func (s *service) fileReorderOn(w http.ResponseWriter, r *http.Request, cc *Comp
bucket := s.bucket()
var items []FileItem
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
files, _, err := sc.visibleFiles(tx)
files, _, err := sc.visibleFilesLocked(tx, true)
if err != nil {
return err
}

View File

@@ -65,9 +65,19 @@ func TestFileuploadCompile(t *testing.T) {
}
err = activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 2\n"))})},
map[string]any{"http.body_limits.upload_bytes": 1048576})
if err == nil || !strings.Contains(err.Error(), "field photos: maxFilesize exceeds http.body_limits.upload_bytes") {
if err == nil || !strings.Contains(err.Error(), "field photos: maxFilesize plus multipart overhead exceeds http.body_limits.upload_bytes") {
t.Fatalf("maxFilesize over upload_bytes: %v", err)
}
// Equality leaves no room for multipart framing.
err = activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 1\n"))})},
map[string]any{"http.body_limits.upload_bytes": 1048576})
if err == nil || !strings.Contains(err.Error(), "field photos: maxFilesize plus multipart overhead exceeds http.body_limits.upload_bytes") {
t.Fatalf("maxFilesize equal to upload_bytes: %v", err)
}
if err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 1\n"))})},
map[string]any{"http.body_limits.upload_bytes": 1048576 + 64<<10}); err != nil {
t.Fatalf("maxFilesize with 64 KiB headroom: %v", err)
}
if err := bootGadgetFields(t, dfGadgetFields(photo(" mode: image\n fileTypes: jpg|png\n mimeTypes: image/png, png\n maxFiles: 2\n imageWidth: 120\n thumbOptions:\n mode: fit\n useCaption: true\n prompt: Drop\n"))); err != nil {
t.Fatalf("every key: %v", err)
}
@@ -332,3 +342,41 @@ func TestFileuploadMIME(t *testing.T) {
}
want(t, "text", env.a.upload(t, dfPath(g, "/files/manual"), "a.txt", []byte("hello"), sk(key)), http.StatusCreated)
}
// TestFileuploadUploadID: a repeated X-Upload-Id returns the stored file
// instead of creating a second binding (CR-02).
func TestFileuploadUploadID(t *testing.T) {
env := newDeferredEnv(t)
key := newSessionKey(t)
h := map[string]string{cabana.SessionKeyHeader: key, "X-Upload-Id": "retry-one"}
first := env.a.upload(t, photosPath(0, ""), "a.png", conformPNG(t), h)
want(t, "first upload", first, http.StatusCreated)
id := dataID(t, first.Body.Bytes())
again := env.a.upload(t, photosPath(0, ""), "b.png", conformPNG(t), h)
want(t, "same upload id", again, http.StatusCreated)
if got := dataID(t, again.Body.Bytes()); got != id {
t.Fatalf("retry created %d, want %d", got, id)
}
if got := fileItems(t, env.a, 0, "photos", key); len(got) != 1 || got[0].ID != id {
t.Fatalf("list = %+v", got)
}
}
// TestJSONBodyCaps: create, update, link, unlink and settings refuse a
// JSON body past http.body_limits.default_bytes (CR-03).
func TestJSONBodyCaps(t *testing.T) {
env := newDeferredEnv(t)
huge := strings.Repeat("x", 1100<<10)
want(t, "create", env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": huge}, nil), http.StatusRequestEntityTooLarge)
g := env.gadget(t, "g-"+env.stamp, false)
want(t, "update", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": huge}, nil), http.StatusRequestEntityTooLarge)
want(t, "link", env.a.do(t, http.MethodPost, dfPath(g, "/relations/members/link"), map[string]any{"ids": []uint{1}, "pad": huge}, nil), http.StatusRequestEntityTooLarge)
want(t, "unlink", env.a.do(t, http.MethodPost, dfPath(g, "/relations/members/unlink"), map[string]any{"ids": []uint{1}, "pad": huge}, nil), http.StatusRequestEntityTooLarge)
settings := newConformEnv(t)
settings.send(t, http.MethodPost, "/auth/login", map[string]string{"login": settings.login, "password": adminTestPassword}, false)
rec := settings.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true, "pad": huge}, true)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("settings: status=%d want %d body=%s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
}
}

View File

@@ -466,7 +466,7 @@ func (s *service) settingsGet(w http.ResponseWriter, r *http.Request) {
func (s *service) settingsPut(w http.ResponseWriter, r *http.Request) {
s.protectSetting(w, r, func(setting *CompiledSetting) {
body, err := decodeObject(r)
body, err := s.decodeCappedObject(w, r)
if err != nil {
writeCRUDError(w, err)
return
@@ -587,7 +587,7 @@ func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link
writeCRUDError(w, err)
return
}
in, err := decodeRelationMutation(r)
in, err := s.decodeCappedRelationMutation(w, r)
if err != nil {
writeCRUDError(w, err)
return
@@ -611,11 +611,19 @@ func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link
}
func decodeRelationMutation(r *http.Request) (RelationMutationInput, error) {
dec := json.NewDecoder(r.Body)
return decodeRelationMutationBody(r.Body)
}
func decodeRelationMutationBody(body io.Reader) (RelationMutationInput, error) {
dec := json.NewDecoder(body)
dec.UseNumber()
dec.DisallowUnknownFields()
var in RelationMutationInput
if err := dec.Decode(&in); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return RelationMutationInput{}, err
}
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
}
var trailing any
@@ -625,6 +633,10 @@ func decodeRelationMutation(r *http.Request) (RelationMutationInput, error) {
return in, nil
}
func (s *service) decodeCappedRelationMutation(w http.ResponseWriter, r *http.Request) (RelationMutationInput, error) {
return decodeRelationMutationBody(http.MaxBytesReader(w, r.Body, s.jsonCap()))
}
func (s *service) relations() (RelationService, error) {
db, err := s.db()
if err != nil {
@@ -758,7 +770,7 @@ func (s *service) create(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err)
return
}
body, err := decodeObject(r)
body, err := s.decodeCappedObject(w, r)
if err != nil {
writeCRUDError(w, err)
return
@@ -792,7 +804,7 @@ func (s *service) update(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err)
return
}
body, err := decodeObject(r)
body, err := s.decodeCappedObject(w, r)
if err != nil {
writeCRUDError(w, err)
return
@@ -816,7 +828,7 @@ func (s *service) bulkDelete(w http.ResponseWriter, r *http.Request) {
if !s.operationDeclared(w, r, cc, "bulk-delete") {
return
}
in, err := decodeBulk(r)
in, err := s.decodeCappedBulk(w, r)
if err != nil {
writeCRUDError(w, err)
return
@@ -836,15 +848,27 @@ func (s *service) bulkDelete(w http.ResponseWriter, r *http.Request) {
}
func decodeBulk(r *http.Request) (BulkDeleteInput, error) {
dec := json.NewDecoder(r.Body)
return decodeBulkBody(r.Body)
}
func decodeBulkBody(body io.Reader) (BulkDeleteInput, error) {
dec := json.NewDecoder(body)
dec.UseNumber()
var in BulkDeleteInput
if err := dec.Decode(&in); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return BulkDeleteInput{}, err
}
return BulkDeleteInput{}, &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
}
return in, nil
}
func (s *service) decodeCappedBulk(w http.ResponseWriter, r *http.Request) (BulkDeleteInput, error) {
return decodeBulkBody(http.MaxBytesReader(w, r.Body, s.jsonCap()))
}
func (s *service) deleteRecord(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if !s.operationDeclared(w, r, cc, "delete") {

View File

@@ -469,7 +469,9 @@ func (s RelationService) ShowPivot(ctx context.Context, cc *CompiledController,
return lifecycleFailure(cc, err)
}
row := cr.Contract.NewPivot()
_ = lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false)
if err := lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false); err != nil {
return lifecycleFailure(cc, err)
}
data = pivotRecord(cr, row, childID)
return nil
}
@@ -564,7 +566,9 @@ func (s RelationService) updatePendingPivot(ctx context.Context, tx *gorm.DB, cc
return nil, lifecycleFailure(cc, err)
}
row := cr.Contract.NewPivot()
_ = lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false)
if err := lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false); err != nil {
return nil, lifecycleFailure(cc, err)
}
if err := s.fillPivot(ctx, tx, cr, row, values); err != nil {
return nil, err
}

View File

@@ -586,3 +586,25 @@ func TestRelationChildPurgeModels(t *testing.T) {
t.Fatalf("listed models failed boot: %v", err)
}
}
// TestPendingPivotFillError: a deferred pivot value that no longer fits
// the column is an error, not a silent zero (WR-04).
func TestPendingPivotFillError(t *testing.T) {
env := newDeferredEnv(t)
key := newSessionKey(t)
h := sk(key)
m := env.member(t, "p-"+env.stamp+"@example.test")
want(t, "link", env.a.do(t, http.MethodPost, dfPath(0, "/relations/members/link"), map[string]any{"ids": []uint{m}, "pivot": map[string]any{"note": "ok"}}, h), http.StatusOK)
bad := `{"created":false,"pivot":{"note":true}}`
if err := env.db.Model(&lagoon.DeferredBinding{}).Where("session_key = ? AND master_field = ?", key, "members").Update("pivot_data", bad).Error; err != nil {
t.Fatal(err)
}
shown := env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), nil, h)
if shown.Code < 400 {
t.Fatalf("show bad pivot: status=%d body=%s", shown.Code, shown.Body.String())
}
edited := env.a.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), map[string]any{"note": "x"}, h)
if edited.Code < 400 {
t.Fatalf("update bad pivot: status=%d body=%s", edited.Code, edited.Body.String())
}
}

View File

@@ -24,7 +24,7 @@ Postgres data layer: the shared GORM connection, per-plugin migrations, model he
- Date and time columns: `lagoon.Date` (a `DATE` column, JSON `"2026-10-02"`) and `lagoon.TimeOfDay` (a `TIME` column, JSON `"14:30:00"`) implement `sql.Scanner`, `driver.Valuer`, JSON and text marshalling, and store NULL for their zero value; `*lagoon.Date` and `*lagoon.TimeOfDay` are the nullable variants, next to `time.Time` and `*time.Time` for `timestamptz`. Build them with `lagoon.NewDate`, `lagoon.DateOf`, `lagoon.ParseDate`, `lagoon.NewTimeOfDay` and `lagoon.ParseTimeOfDay`. `lagoon.Fill` fills all six from JSON strings (RFC 3339 for `time.Time`) through their text unmarshalling, after every conversion it already made. Behaviour change: `required` now treats a zero `time.Time`, `lagoon.Date` or `lagoon.TimeOfDay` (or a pointer to one) as empty, so declare optional dates as pointer fields.
- 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.
- Lifecycle and relations: hook interfaces matching GORM's native method names (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) plus `lagoon.HasBeforeValidate`; `lagoon.WithSoftDeleteCascade` runs a cascade inside the parent delete; `lagoon.RegisterJoinTable` wires pivot models with business columns.
- Deferred binding: WinterCMS's `deferred_bindings` table holds the uploads and related-record changes of a form whose record is not saved yet. Every operation takes a `lagoon.DeferredKey` (the form's session key, the owning backend admin's id and the master record's morph type from `lagoon.MorphType`) and never reads or changes another admin's rows, since each row stores `backend_user_id`. `lagoon.DeferredBind` and `lagoon.DeferredUnbind` port WinterCMS's duplicate and cancel rules: a repeated bind writes nothing, and an unbind of a slave with a pending bind deletes that bind and returns it so the caller can remove what it created. `lagoon.DeferredBindings` reads and locks a session's bindings for the save that commits them, `lagoon.DeferredForget` deletes them once applied, and `lagoon.DeferredSlaves` is the subquery a list uses to include pending rows. A child created under deferral carries the `lagoon.DeferredEnvelope` (`{"created":true,"pivot":{...}}`) in `pivot_data`. `lagoon.PurgeDeferred` removes expired bindings: it deletes an unattached `system_files` row a bind points at, and its blobs only after the commit, deletes a child only when its binding carries the created envelope, keeps records that were only linked, and locks each batch with `FOR UPDATE SKIP LOCKED`.
- Deferred binding: WinterCMS's `deferred_bindings` table holds the uploads and related-record changes of a form whose record is not saved yet. Every operation takes a `lagoon.DeferredKey` (the form's session key, the owning backend admin's id and the master record's morph type from `lagoon.MorphType`) and never reads or changes another admin's rows, since each row stores `backend_user_id`. `lagoon.DeferredBind` and `lagoon.DeferredUnbind` port WinterCMS's duplicate and cancel rules: a repeated bind writes nothing, and an unbind of a slave with a pending bind deletes that bind and returns it so the caller can remove what it created. `lagoon.DeferredBindings` reads and locks a session's bindings for the save that commits them, `lagoon.DeferredForget` deletes them once applied, and `lagoon.DeferredSlaves` is the subquery a list uses to include pending rows. A child created under deferral carries the `lagoon.DeferredEnvelope` (`{"created":true,"pivot":{...},"upload_id":"..."}`) in `pivot_data`. `lagoon.PurgeDeferred` removes expired bindings: it deletes an unattached `system_files` row a bind points at, and its blobs only after the commit, deletes a child only when its binding carries the created envelope, keeps records that were only linked, and locks each batch with `FOR UPDATE SKIP LOCKED`.
- Imports from Laravel: `lagoon.DecryptLaravelPayload` decrypts Laravel `encrypted` payloads with the old application key, for one-off data imports.
- Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), public URLs (`attach.PublicURL` for any key, `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since WebP cannot be encoded; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming and, in image mode, the `attach.IsAllowedImage` content guard), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`).
@@ -133,7 +133,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `lagoon.DeferredHistoryID` | History id of the deferred-binding set, `summercms.deferred`. |
| `lagoon.DeferredBinding` | The `deferred_bindings` row model; `lagoon.DeferredBinding.Envelope` decodes its `pivot_data`. |
| `lagoon.DeferredKey` | Session key, admin id and master type that scope every deferred-binding operation. |
| `lagoon.DeferredEnvelope` | The framework's `pivot_data` shape: `Created` marks a child created under deferral, `Pivot` holds pivot values. |
| `lagoon.DeferredEnvelope` | The framework's `pivot_data` shape: `Created` marks a child created under deferral, `Pivot` holds pivot values, `UploadID` is the client upload id of a deferred file. |
| `lagoon.DeferredFileType` | The `slave_type` of a binding that points at a `system_files` row. |
| `lagoon.MorphType` | The `master_type` or `slave_type` string of a model: its `attach.Owner` morph name, else its table name. |
| `lagoon.DeferredBind` | Records a pending bind; a repeat writes nothing and a pending unbind of the same slave is cancelled. |

View File

@@ -69,8 +69,9 @@ func (k DeferredKey) validate() error {
// holds the pivot values of a deferred belongsToMany link. A binding without
// the envelope (nil pivot_data, or JSON without these keys) is a plain link.
type DeferredEnvelope struct {
Created bool `json:"created,omitempty"`
Pivot map[string]any `json:"pivot,omitempty"`
Created bool `json:"created,omitempty"`
Pivot map[string]any `json:"pivot,omitempty"`
UploadID string `json:"upload_id,omitempty"`
}
// Envelope decodes the binding's pivot_data. Nil or empty pivot_data is the
@@ -167,7 +168,7 @@ func insertBinding(ctx context.Context, tx *gorm.DB, key DeferredKey, field, sla
IsBind: bind,
BackendUserID: key.AdminID,
}
if env != nil && (env.Created || len(env.Pivot) > 0) {
if env != nil && (env.Created || len(env.Pivot) > 0 || env.UploadID != "") {
raw, err := json.Marshal(env)
if err != nil {
return fmt.Errorf("lagoon: deferred binding envelope: %w", err)

View File

@@ -362,15 +362,19 @@ func TestDeferredStore(t *testing.T) {
if err := DeferredBind(ctx, tx, key, "members", "acme_members", "2", &DeferredEnvelope{Pivot: map[string]any{"note": "hi"}}); err != nil {
return err
}
// An empty envelope stores no pivot_data.
return DeferredBind(ctx, tx, key, "members", "acme_members", "3", &DeferredEnvelope{})
// An empty envelope stores no pivot_data; upload_id alone is stored.
if err := DeferredBind(ctx, tx, key, "members", "acme_members", "3", &DeferredEnvelope{}); err != nil {
return err
}
return DeferredBind(ctx, tx, key, "photos", "system_files", "4", &DeferredEnvelope{UploadID: "retry-one"})
})
rows := bindingRows(t, gdb)
if len(rows) != 3 || rows[0].PivotData == nil || *rows[0].PivotData != `{"created":true}` ||
rows[1].PivotData == nil || *rows[1].PivotData != `{"pivot":{"note":"hi"}}` || rows[2].PivotData != nil {
if len(rows) != 4 || rows[0].PivotData == nil || *rows[0].PivotData != `{"created":true}` ||
rows[1].PivotData == nil || *rows[1].PivotData != `{"pivot":{"note":"hi"}}` || rows[2].PivotData != nil ||
rows[3].PivotData == nil || *rows[3].PivotData != `{"upload_id":"retry-one"}` {
t.Fatalf("pivot_data %+v", rows)
}
for i, want := range []DeferredEnvelope{{Created: true}, {Pivot: map[string]any{"note": "hi"}}, {}} {
for i, want := range []DeferredEnvelope{{Created: true}, {Pivot: map[string]any{"note": "hi"}}, {}, {UploadID: "retry-one"}} {
env, err := rows[i].Envelope()
if err != nil || fmt.Sprint(env) != fmt.Sprint(want) {
t.Fatalf("envelope %d = %+v %v", i, env, err)