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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user