feat(12.2-03): defer relation work on unsaved records and add child file routes
- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
This commit is contained in:
@@ -52,6 +52,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
||||
| GET and PUT `.../{id}/relations/{name}/records/{child}` | Show a child (needs `update` or a view form) and save it through the manage form (needs `update`). |
|
||||
| POST `.../{id}/relations/{name}/delete` | `{ids}`: delete children through their model (needs `delete`). A belongsToMany record loses this record's pivot row first. All or nothing. |
|
||||
| GET and PUT `.../{id}/relations/{name}/pivot/{child}` | Read and save the pivot form values of one link (needs a pivot form and `link` or `update`). |
|
||||
| GET and POST `.../{id}/relations/{name}/records/{child}/files/{field}`, PUT and DELETE `.../files/{field}/{file}`, POST `.../files/{field}/reorder`, GET `.../files/{field}/{file}/download` and `/thumb` | The file routes of a `fileupload` field in the relation's manage form, keyed by `X-Child-Session-Key`; `{child}` 0 is the child being created. |
|
||||
| GET `.../{id}/files/{field}` | The files of a `type: fileupload` field: attached files minus the session's pending removals, plus its pending uploads, in `sort_order`. `{id}` 0 is the record being created and needs `X-Session-Key`. |
|
||||
| POST `.../{id}/files/{field}` | Upload one multipart `file_data` part; it is bound to the `X-Session-Key` session (required) and attached by the record's next save. Answers 201 with the pending `cabana.FileItem`. |
|
||||
| PUT `.../{id}/files/{field}/{file}` | Save a file's `title` and `description` at once; the field must declare `useCaption` (403 otherwise). |
|
||||
@@ -59,6 +60,8 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
||||
| POST `.../{id}/files/{field}/reorder` | `{ids}` in the new order, exactly the field's visible files; they take the existing `sort_order` values at once. attachMany only (403 otherwise). |
|
||||
| GET `.../{id}/files/{field}/{file}/download`, GET `.../{id}/files/{field}/{file}/thumb` | Stream a protected file or its preview thumbnail; see the protected files note below. |
|
||||
|
||||
Every relation route accepts `{id}` 0 for the record being created when the relation is deferrable (a belongsToMany, or a hasMany with a nullable foreign key), the request carries `X-Session-Key` and the controller declares create: children created, linked, unlinked and deleted there are held against the key and applied by the record's first save, in its transaction, and an ineligible link answers 422 on the `relation-manager` field. A deferrable relation that declares `create` needs its related model in some plugin's `pact.HasModels` list, so `deferred:purge` can delete abandoned children; boot fails otherwise. Existing belongsToMany managers without `context: update` therefore appear on create screens.
|
||||
|
||||
Every relation child and pivot route loads the record through `pact.FormExtendQuery` and finds the child with one query that carries the record: a hasMany child by its foreign key, a belongsToMany record by a pivot row. A child of another record is `not_found`, never `forbidden`.
|
||||
|
||||
Every file route resolves its file with one query scoped to the record (loaded through `pact.FormExtendQuery`) or to the administrator's own pending uploads: a file of another record is `not_found`, never `forbidden`. The save applies the session's file work in its transaction: a pending upload on an attachOne field replaces the file attached before, a deferred removal deletes the attached file, and the blobs of deleted files are removed after commit. After that the save checks `maxFiles` and a `required` fileupload field (at least one file) and answers 422 on the field when either fails; the pending work stays for the next attempt.
|
||||
@@ -165,7 +168,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. |
|
||||
| `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options. |
|
||||
| `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. |
|
||||
| `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers. |
|
||||
| `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers, child create, show, update and delete (`CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`) and pivot values (`ShowPivot`, `UpdatePivot`); its `SessionKey` makes record id 0 the record being created in that session. |
|
||||
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. |
|
||||
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. |
|
||||
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. |
|
||||
@@ -182,13 +185,15 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.AdminActionResult` | Answer of an action route: the localized `message` and the filtered `fill` object. |
|
||||
| `cabana.ControllerAssets` | The `assets` object of list and form schemas: `scripts` and `styles` URL lists, always arrays. |
|
||||
| `cabana.ToolbarAction` | One registered toolbar button in a list schema's `toolbarActions`: action name and localized label. |
|
||||
| `cabana.SessionKeyHeader` | `X-Session-Key`, the header that carries the form session key of deferred file work. |
|
||||
| `cabana.SessionKeyHeader` | `X-Session-Key`, the header that carries the form session key of deferred file and relation work. |
|
||||
| `cabana.ChildSessionKeyHeader` | `X-Child-Session-Key`, the header that carries a relation child form's own session key for its file uploads. |
|
||||
| `cabana.RecordInput` | A create or update body (`Body`) and the form session key (`SessionKey`) whose file bindings the save applies. |
|
||||
| `cabana.FileItem` | One file of a fileupload field: id, name, size, content type, title, description, `sort_order`, `pending`, and `url`/`thumb_url` for public relations. |
|
||||
| `cabana.ThumbOptions` | A fileupload field's `thumbOptions` (the preview thumbnail `mode`). |
|
||||
| `cabana.FileMutationResult` | Answer of the file removal route: `removed`. |
|
||||
| `cabana.AdminFileCaptionRequest` | Body of the file caption route: optional `title` and `description`. Unknown keys are refused. |
|
||||
| `cabana.AdminFileList` / `cabana.AdminFileUpload` / `cabana.AdminFileUpdate` / `cabana.AdminFileRemove` / `cabana.AdminFileReorder` / `cabana.AdminFileDownload` / `cabana.AdminFileThumb` | Swag annotations of the file routes. |
|
||||
| `cabana.AdminRelationChildFileList` / `cabana.AdminRelationChildFileUpload` / `cabana.AdminRelationChildFileUpdate` / `cabana.AdminRelationChildFileRemove` / `cabana.AdminRelationChildFileReorder` / `cabana.AdminRelationChildFileDownload` / `cabana.AdminRelationChildFileThumb` | Swag annotations of the relation child file routes. |
|
||||
| `cabana.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -539,13 +539,14 @@ func AdminDelete() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param search query string false "Search term over the panel's searchable columns"
|
||||
// @Param sort query string false "Sort column (a sortable panel column)"
|
||||
// @Param dir query string false "Sort direction (asc or desc)"
|
||||
// @Param page query integer false "Page"
|
||||
// @Param per_page query integer false "Records per page (1-100, default 20)"
|
||||
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
|
||||
// @Success 200 {object} ListEnvelope[[]AdminRecord]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -563,13 +564,14 @@ func AdminRelationLinked() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param search query string false "Search term over the panel's searchable columns"
|
||||
// @Param sort query string false "Sort column (a sortable panel column)"
|
||||
// @Param dir query string false "Sort direction (asc or desc)"
|
||||
// @Param page query integer false "Page"
|
||||
// @Param per_page query integer false "Records per page (1-100, default 20)"
|
||||
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
|
||||
// @Success 200 {object} ListEnvelope[[]AdminRecord]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -588,9 +590,10 @@ func AdminRelationCandidates() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param body body AdminRelationLinkRequest true "Related record ids and optional pivot form values"
|
||||
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
|
||||
// @Success 200 {object} Envelope[RelationMutationResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -609,9 +612,10 @@ func AdminRelationLink() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param body body AdminIDsRequest true "Related record ids"
|
||||
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
|
||||
// @Success 200 {object} Envelope[RelationMutationResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -631,9 +635,11 @@ func AdminRelationUnlink() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param body body AdminRecord true "Field values of the manage form keyed by field name"
|
||||
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
|
||||
// @Param X-Child-Session-Key header string false "Child form session key: the save attaches the child files uploaded under it"
|
||||
// @Success 201 {object} RecordEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -653,9 +659,10 @@ func AdminRelationChildCreate() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param child path integer true "Related record id"
|
||||
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
|
||||
// @Success 200 {object} RecordEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -675,10 +682,12 @@ func AdminRelationChildShow() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param child path integer true "Related record id"
|
||||
// @Param body body AdminRecord true "Field values of the manage form keyed by field name"
|
||||
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
|
||||
// @Param X-Child-Session-Key header string false "Child form session key: the save attaches the child files uploaded under it"
|
||||
// @Success 200 {object} RecordEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -699,9 +708,10 @@ func AdminRelationChildUpdate() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param body body AdminIDsRequest true "Related record ids"
|
||||
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
|
||||
// @Success 200 {object} Envelope[BulkResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -721,9 +731,10 @@ func AdminRelationChildDelete() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param child path integer true "Related record id"
|
||||
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
|
||||
// @Success 200 {object} Envelope[AdminRecord]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -743,10 +754,11 @@ func AdminRelationPivotShow() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param child path integer true "Related record id"
|
||||
// @Param body body AdminRecord true "Pivot form values keyed by field name"
|
||||
// @Param X-Session-Key header string false "Form session key; with it, owner id 0 is the record being created in that session"
|
||||
// @Success 200 {object} Envelope[AdminRecord]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -756,6 +768,194 @@ func AdminRelationPivotShow() {}
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child} [put]
|
||||
func AdminRelationPivotUpdate() {}
|
||||
|
||||
// AdminRelationChildFileList documents the file list of a relation child
|
||||
// form's fileupload field.
|
||||
//
|
||||
// @Summary List the files of a related record's fileupload field
|
||||
// @Description The child-form counterpart of the record file list: the files attached to the related record minus the X-Child-Session-Key session's pending removals, plus its pending uploads. The related record is scoped to the owner like the child show route; child 0 needs the create toolbar button and the child key, a saved child the update button or a view form (403 otherwise).
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param child path integer true "Related record id (0 for the child being created)"
|
||||
// @Param field path string true "fileupload field of the relation's manage form"
|
||||
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
|
||||
// @Param X-Child-Session-Key header string false "Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads"
|
||||
// @Success 200 {object} Envelope[[]FileItem]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field} [get]
|
||||
func AdminRelationChildFileList() {}
|
||||
|
||||
// AdminRelationChildFileUpload documents an upload to a relation child
|
||||
// form's fileupload field.
|
||||
//
|
||||
// @Summary Upload a file to a related record's fileupload field
|
||||
// @Description Stores one multipart file_data part and binds it to the X-Child-Session-Key session; the child's create or update save with the same key attaches it. Limits and errors as on the record upload route. Writes to a saved child need the update toolbar button (403 otherwise).
|
||||
// @Tags admin
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param child path integer true "Related record id (0 for the child being created)"
|
||||
// @Param field path string true "fileupload field of the relation's manage form"
|
||||
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
|
||||
// @Param X-Child-Session-Key header string true "Child form session key (32-128 characters of A-Z a-z 0-9 _ -)"
|
||||
// @Param file_data formData file true "The file"
|
||||
// @Success 201 {object} Envelope[FileItem]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 413 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field} [post]
|
||||
func AdminRelationChildFileUpload() {}
|
||||
|
||||
// AdminRelationChildFileUpdate documents the caption route of a relation
|
||||
// child form's fileupload field.
|
||||
//
|
||||
// @Summary Save a related record file's title and description
|
||||
// @Description As the record caption route, for a file of the related record or of the child session. The field must declare useCaption (403 otherwise).
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param child path integer true "Related record id (0 for the child being created)"
|
||||
// @Param field path string true "fileupload field of the relation's manage form"
|
||||
// @Param file path integer true "File id"
|
||||
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
|
||||
// @Param X-Child-Session-Key header string false "Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads"
|
||||
// @Param body body AdminFileCaptionRequest true "Title and description"
|
||||
// @Success 200 {object} Envelope[FileItem]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 413 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file} [put]
|
||||
func AdminRelationChildFileUpdate() {}
|
||||
|
||||
// AdminRelationChildFileRemove documents the removal of a file from a
|
||||
// relation child form's fileupload field.
|
||||
//
|
||||
// @Summary Remove a related record's file
|
||||
// @Description Removing an attached file is deferred to the child's next save with the same X-Child-Session-Key; removing a pending upload deletes it at once.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param child path integer true "Related record id (0 for the child being created)"
|
||||
// @Param field path string true "fileupload field of the relation's manage form"
|
||||
// @Param file path integer true "File id"
|
||||
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
|
||||
// @Param X-Child-Session-Key header string true "Child form session key (32-128 characters of A-Z a-z 0-9 _ -)"
|
||||
// @Success 200 {object} Envelope[FileMutationResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file} [delete]
|
||||
func AdminRelationChildFileRemove() {}
|
||||
|
||||
// AdminRelationChildFileReorder documents the reorder route of a relation
|
||||
// child form's attachMany field.
|
||||
//
|
||||
// @Summary Reorder a related record's files
|
||||
// @Description As the record reorder route: ids must be exactly the field's visible files. attachMany only, otherwise 403.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param child path integer true "Related record id (0 for the child being created)"
|
||||
// @Param field path string true "fileupload field of the relation's manage form"
|
||||
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
|
||||
// @Param X-Child-Session-Key header string false "Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads"
|
||||
// @Param body body AdminIDsRequest true "File ids in the new order"
|
||||
// @Success 200 {object} Envelope[[]FileItem]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 413 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder [post]
|
||||
func AdminRelationChildFileReorder() {}
|
||||
|
||||
// AdminRelationChildFileDownload documents the download of a related
|
||||
// record's protected file.
|
||||
//
|
||||
// @Summary Download a related record's protected file
|
||||
// @Description As the record download route, for a protected file of the related record or of the child session, with the same headers.
|
||||
// @Tags admin
|
||||
// @Produce octet-stream
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param child path integer true "Related record id (0 for the child being created)"
|
||||
// @Param field path string true "fileupload field of the relation's manage form"
|
||||
// @Param file path integer true "File id"
|
||||
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
|
||||
// @Param X-Child-Session-Key header string false "Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads"
|
||||
// @Success 200 {file} file
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download [get]
|
||||
func AdminRelationChildFileDownload() {}
|
||||
|
||||
// AdminRelationChildFileThumb documents the thumbnail of a related
|
||||
// record's protected image.
|
||||
//
|
||||
// @Summary Thumbnail of a related record's protected image
|
||||
// @Description As the record thumb route, for a protected image of the related record or of the child session.
|
||||
// @Tags admin
|
||||
// @Produce octet-stream
|
||||
// @Security BackendBearer
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id (0 for the record being created)"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param child path integer true "Related record id (0 for the child being created)"
|
||||
// @Param field path string true "fileupload field of the relation's manage form"
|
||||
// @Param file path integer true "File id"
|
||||
// @Param X-Session-Key header string false "Owner form session key; needed when the owner id is 0"
|
||||
// @Param X-Child-Session-Key header string false "Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads"
|
||||
// @Success 200 {file} file
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb [get]
|
||||
func AdminRelationChildFileThumb() {}
|
||||
|
||||
// FileMutationResult is the payload of a file removal: the number of files
|
||||
// removed (always 1 on success).
|
||||
type FileMutationResult struct {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -21,6 +22,12 @@ import (
|
||||
// by the record's next create or update save, inside its transaction.
|
||||
const SessionKeyHeader = "X-Session-Key"
|
||||
|
||||
// ChildSessionKeyHeader carries the session key of a relation child form
|
||||
// (D-17): the child modal's own form key, sent with the child's file calls
|
||||
// and with its create or update, which applies the child's file bindings.
|
||||
// A child modal on a record that is not saved yet carries both headers.
|
||||
const ChildSessionKeyHeader = "X-Child-Session-Key"
|
||||
|
||||
// sessionKeyPattern is the accepted key shape: 32 to 128 URL-safe
|
||||
// characters, at least 128 bits for a base64url key.
|
||||
var sessionKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{32,128}$`)
|
||||
@@ -38,21 +45,39 @@ func sessionKeyFrom(r *http.Request) (string, bool, error) {
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
// commitDeferred applies the file bindings of in.SessionKey to the saved
|
||||
// target inside the save transaction (D-04), then rechecks the file limits.
|
||||
// childSessionKeyFrom reads the X-Child-Session-Key header like
|
||||
// sessionKeyFrom; a malformed key is a validation error on
|
||||
// child_session_key.
|
||||
func childSessionKeyFrom(r *http.Request) (string, bool, error) {
|
||||
raw := strings.TrimSpace(r.Header.Get(ChildSessionKeyHeader))
|
||||
if raw == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
if !sessionKeyPattern.MatchString(raw) {
|
||||
return "", false, &ValidationError{Details: map[string]any{"child_session_key": []string{"The child session key is invalid."}}}
|
||||
}
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
// commitDeferred applies the file and relation bindings of in.SessionKey to
|
||||
// the saved target inside the save transaction (D-04), then rechecks the
|
||||
// file limits.
|
||||
//
|
||||
// It reads every binding of the key, the authenticated admin and the
|
||||
// controller's morph type whose master_field is a fileupload field allowed
|
||||
// in op, locked FOR UPDATE so two saves with one key serialize, and applies
|
||||
// them in id order: a bind attaches its pending upload (on an attachOne
|
||||
// field after deleting the file it replaces), an unbind deletes the attached
|
||||
// file. Blobs of deleted files are removed after commit, and the applied
|
||||
// rows are deleted. Bindings of other fields stay for the purge. Then every
|
||||
// fileupload field allowed in op must hold at most maxFiles files and, when
|
||||
// required, at least one; otherwise the save fails with a 422 on the field,
|
||||
// the transaction rolls back and the bindings stay in place.
|
||||
// controller's morph type whose master_field is a fileupload field or a
|
||||
// deferrable relation-manager relation allowed in op, locked FOR UPDATE so
|
||||
// two saves with one key serialize, and applies them in id order. A file
|
||||
// bind attaches its pending upload (on an attachOne field after deleting
|
||||
// the file it replaces), a file unbind deletes the attached file; blobs of
|
||||
// deleted files are removed after commit. A relation bind attaches the
|
||||
// related record (applyRelationBinding), a relation unbind detaches it. The
|
||||
// applied rows are deleted; bindings of other fields stay for the purge.
|
||||
// Then every fileupload field allowed in op must hold at most maxFiles files
|
||||
// and, when required, at least one. Any failure (a 422 on a file field, or
|
||||
// on a relation-manager field for an ineligible link) rolls the transaction
|
||||
// back and leaves the bindings in place.
|
||||
func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, op string, in RecordInput) error {
|
||||
if cc == nil || cc.Form == nil || len(cc.files) == 0 {
|
||||
if cc == nil || cc.Form == nil {
|
||||
return nil
|
||||
}
|
||||
var fields []*compiledFile
|
||||
@@ -61,8 +86,14 @@ func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *Compil
|
||||
fields = append(fields, cf)
|
||||
}
|
||||
}
|
||||
relations := map[string]*CompiledRelation{}
|
||||
for name, cr := range cc.Relations {
|
||||
if cr != nil && cr.deferrable && contextAllows(cc, cr.fieldName, op) {
|
||||
relations[name] = cr
|
||||
}
|
||||
}
|
||||
ownerID := primaryText(target)
|
||||
if len(fields) == 0 || ownerID == "" {
|
||||
if (len(fields) == 0 && len(relations) == 0) || ownerID == "" {
|
||||
return nil
|
||||
}
|
||||
morph, err := lagoon.MorphType(tx, target)
|
||||
@@ -71,30 +102,45 @@ func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *Compil
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if in.SessionKey != "" && principal != nil && principal.Backend && principal.ID != 0 {
|
||||
names := make([]string, len(fields))
|
||||
for i, cf := range fields {
|
||||
names[i] = cf.name
|
||||
names := make([]string, 0, len(fields)+len(relations))
|
||||
for _, cf := range fields {
|
||||
names = append(names, cf.name)
|
||||
}
|
||||
for name := range relations {
|
||||
names = append(names, name)
|
||||
}
|
||||
key := lagoon.DeferredKey{SessionKey: in.SessionKey, AdminID: principal.ID, MasterType: morph}
|
||||
rows, err := lagoon.DeferredBindings(ctx, tx, key, names)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
rel := RelationService{DB: s.DB, bucket: s.bucket, tr: s.tr}
|
||||
parent := &relationParent{model: target, id: pkUint(target)}
|
||||
applied := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
cf := cc.files[row.MasterField]
|
||||
if cf == nil || row.SlaveType != lagoon.DeferredFileType {
|
||||
if cf := cc.files[row.MasterField]; cf != nil && row.SlaveType == lagoon.DeferredFileType {
|
||||
if row.IsBind {
|
||||
err = s.applyFileBind(ctx, tx, cf, morph, ownerID, row)
|
||||
} else {
|
||||
err = s.applyFileUnbind(ctx, tx, cf, morph, ownerID, row)
|
||||
}
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
applied = append(applied, row.ID)
|
||||
continue
|
||||
}
|
||||
if row.IsBind {
|
||||
err = s.applyFileBind(ctx, tx, cf, morph, ownerID, row)
|
||||
} else {
|
||||
err = s.applyFileUnbind(ctx, tx, cf, morph, ownerID, row)
|
||||
cr := relations[row.MasterField]
|
||||
if cr == nil {
|
||||
continue
|
||||
}
|
||||
done, err := rel.applyRelationBinding(ctx, tx, cc, cr, parent, row)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
applied = append(applied, row.ID)
|
||||
if done {
|
||||
applied = append(applied, row.ID)
|
||||
}
|
||||
}
|
||||
if err := lagoon.DeferredForget(ctx, tx, applied); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
@@ -125,6 +171,95 @@ func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *Compil
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyRelationBinding applies one relation binding of a form session to
|
||||
// the saved parent (D-04) and reports whether the row was consumed. A
|
||||
// binding of another slave type is left alone. A bind of a child the
|
||||
// session created attaches it directly: a hasMany child whose ForeignKey is
|
||||
// still NULL gets the parent's key through its model (a child gone or owned
|
||||
// elsewhere is skipped); a belongsToMany record gets its pivot row with the
|
||||
// envelope's pivot values and RelationBeforeLink stamps. A bind of an
|
||||
// existing record runs the shared link path with the saved parent, so the
|
||||
// eligibility checks (RelationExtendManageQuery, ExcludedRelatedIDs, not
|
||||
// linked yet) run again; an ineligible record fails the save with a 422 on
|
||||
// the relation-manager field. An unbind runs the shared unlink path.
|
||||
func (s RelationService) applyRelationBinding(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, row lagoon.DeferredBinding) (bool, error) {
|
||||
morph, err := lagoon.MorphType(tx, cr.Contract.NewRelated())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if row.SlaveType != morph {
|
||||
return false, nil
|
||||
}
|
||||
n, err := strconv.ParseUint(row.SlaveID, 10, 64)
|
||||
if err != nil || n == 0 {
|
||||
return true, nil
|
||||
}
|
||||
id := uint(n)
|
||||
if !row.IsBind {
|
||||
_, err := s.unlinkRelated(ctx, tx, cc, cr, parent.model, []uint{id})
|
||||
return true, err
|
||||
}
|
||||
env, err := row.Envelope()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var pivot map[string]any
|
||||
if len(env.Pivot) > 0 && cr.pivot != nil {
|
||||
pivot = env.Pivot
|
||||
}
|
||||
if !env.Created {
|
||||
_, err := s.linkRelated(ctx, tx, cc, cr, parent.model, []uint{id}, pivot, relationInvalid(cr.fieldName, "contains an ineligible record"))
|
||||
return true, err
|
||||
}
|
||||
child := cr.Contract.NewRelated()
|
||||
err = tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where(clause.Eq{Column: clause.Column{Name: primaryColumn(child)}, Value: castPK(child, id)}).Take(child).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cr.hasMany() {
|
||||
if fk, ok := structFieldValue(child, cr.Contract.ForeignKey); !ok || !fk.IsNil() {
|
||||
return true, nil
|
||||
}
|
||||
if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, tx.WithContext(ctx).Save(child).Error
|
||||
}
|
||||
linked, err := pendingRelationIDs(tx, cr, parent.id, []uint{id})
|
||||
if err != nil || len(linked) == 0 {
|
||||
return true, err
|
||||
}
|
||||
var pivotRow any
|
||||
if pivot != nil {
|
||||
pivotRow = cr.Contract.NewPivot()
|
||||
if err := s.fillPivot(ctx, tx, cr, pivotRow, pivot); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, insertPivot(ctx, tx, cc, cr, parent.model, child, pivotRow)
|
||||
}
|
||||
|
||||
// structFieldValue is the value of a pointer field stored in column, false
|
||||
// when the column is not a pointer field.
|
||||
func structFieldValue(model any, column string) (reflect.Value, bool) {
|
||||
v := reflect.ValueOf(model)
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return reflect.Value{}, false
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
f := fieldByColumn(v, column)
|
||||
if !f.IsValid() || f.Kind() != reflect.Pointer {
|
||||
return reflect.Value{}, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
|
||||
// applyFileBind attaches a pending upload to the owner. A row that is gone
|
||||
// or already attached somewhere is ignored. On an attachOne field the file
|
||||
// it replaces is deleted first (WinterCMS's AttachOne::add).
|
||||
|
||||
@@ -582,128 +582,128 @@ func fileItem(ctx context.Context, bucket *blob.Bucket, cf *compiledFile, f *att
|
||||
|
||||
// fileList serves GET .../{id}/files/{field} (dispatched by nestedGet).
|
||||
func (s *service) fileList(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
db, err := s.db()
|
||||
s.protect(w, r, func(cc *CompiledController) { s.fileListOn(w, r, cc, parentFiles(cc)) })
|
||||
}
|
||||
|
||||
func (s *service) fileListOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
bucket := s.bucket()
|
||||
var items []FileItem
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
sc, err := fr.scope(ctx, tx, r)
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
return err
|
||||
}
|
||||
bucket := s.bucket()
|
||||
var items []FileItem
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
sc, err := parentFileScope(ctx, tx, r, cc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files, pending, err := sc.visibleFiles(tx)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
items = make([]FileItem, 0, len(files))
|
||||
for i := range files {
|
||||
items = append(items, fileItem(ctx, bucket, sc.file, &files[i], pending[files[i].ID]))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
files, pending, err := sc.visibleFiles(tx)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
WriteData(w, http.StatusOK, items, nil)
|
||||
items = make([]FileItem, 0, len(files))
|
||||
for i := range files {
|
||||
items = append(items, fileItem(ctx, bucket, sc.file, &files[i], pending[files[i].ID]))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, items, nil)
|
||||
}
|
||||
|
||||
// fileUpload serves POST .../{id}/files/{field}: it stores one multipart
|
||||
// file_data part with attach.Store and binds it to the session key (D-03).
|
||||
// The record's next save attaches it.
|
||||
func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
cf := cc.files[r.PathValue("field")]
|
||||
if cf == nil {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, ok, err := sessionKeyFrom(r); err != nil || !ok {
|
||||
if err == nil {
|
||||
err = &ValidationError{Details: map[string]any{"session_key": []string{"The session key field is required."}}}
|
||||
}
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
db, err := s.db()
|
||||
s.protect(w, r, func(cc *CompiledController) { s.fileUploadOn(w, r, cc, parentFiles(cc)) })
|
||||
}
|
||||
|
||||
func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
|
||||
cf := fr.field(r)
|
||||
if cf == nil {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !requireKey(w, r, fr) {
|
||||
return
|
||||
}
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
bucket := s.bucket()
|
||||
if bucket == nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: file upload without a storage bucket", "controller", controllerID(cc))
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))}
|
||||
r.Body = io.NopCloser(body)
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
writeCRUDError(w, invalidBody())
|
||||
return
|
||||
}
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
s.writeFileError(w, r, cf, body, err)
|
||||
return
|
||||
}
|
||||
if part.FormName() != "file_data" || strings.TrimSpace(part.FileName()) == "" {
|
||||
writeCRUDError(w, invalidBody())
|
||||
return
|
||||
}
|
||||
var stored *attach.File
|
||||
var item FileItem
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
sc, err := fr.scope(ctx, tx, r)
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
return err
|
||||
}
|
||||
bucket := s.bucket()
|
||||
if bucket == nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: file upload without a storage bucket", "controller", controllerID(cc))
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))}
|
||||
r.Body = io.NopCloser(body)
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
writeCRUDError(w, invalidBody())
|
||||
return
|
||||
}
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
s.writeFileError(w, r, cf, body, err)
|
||||
return
|
||||
}
|
||||
if part.FormName() != "file_data" || strings.TrimSpace(part.FileName()) == "" {
|
||||
writeCRUDError(w, invalidBody())
|
||||
return
|
||||
}
|
||||
var stored *attach.File
|
||||
var item FileItem
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
sc, err := parentFileScope(ctx, tx, r, cc)
|
||||
if cf.relation.Many && cf.maxFiles > 0 {
|
||||
files, _, err := sc.visibleFiles(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cf.relation.Many && cf.maxFiles > 0 {
|
||||
files, _, err := sc.visibleFiles(tx)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
if len(files) >= cf.maxFiles {
|
||||
return &ValidationError{Details: fieldDetail(cf.name, fieldMessage(ctx, s.translator(), "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)}))}
|
||||
}
|
||||
}
|
||||
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: cf.relation.Public}, cf.limits)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored = f
|
||||
// Exactly one part: anything after file_data is refused.
|
||||
if _, err := mr.NextPart(); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return invalidBody()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), nil); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
item = fileItem(ctx, bucket, cf, f, true)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// attach.Store wrote the blob before the row; a rolled-back
|
||||
// transaction leaves it to this caller (12.2-01).
|
||||
if stored != nil {
|
||||
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
|
||||
if len(files) >= cf.maxFiles {
|
||||
return &ValidationError{Details: fieldDetail(cf.name, fieldMessage(ctx, s.translator(), "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)}))}
|
||||
}
|
||||
s.writeFileError(w, r, cf, body, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusCreated, item, nil)
|
||||
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: cf.relation.Public}, cf.limits)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored = f
|
||||
// Exactly one part: anything after file_data is refused.
|
||||
if _, err := mr.NextPart(); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return invalidBody()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), nil); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
item = fileItem(ctx, bucket, cf, f, true)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// attach.Store wrote the blob before the row; a rolled-back
|
||||
// transaction leaves it to this caller (12.2-01).
|
||||
if stored != nil {
|
||||
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
|
||||
}
|
||||
s.writeFileError(w, r, cf, body, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusCreated, item, nil)
|
||||
}
|
||||
|
||||
// uploadCap is the request body cap of an upload: the smaller of
|
||||
@@ -916,7 +916,7 @@ func (sc *fileScope) findFile(ctx context.Context, tx *gorm.DB, id uint, lock bo
|
||||
|
||||
// withFileScope runs fn on the resolved scope of a file route inside one
|
||||
// transaction and writes the error envelope on failure.
|
||||
func (s *service) withFileScope(w http.ResponseWriter, r *http.Request, cc *CompiledController, fn func(ctx context.Context, tx *gorm.DB, sc *fileScope) error) bool {
|
||||
func (s *service) withFileScope(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute, fn func(ctx context.Context, tx *gorm.DB, sc *fileScope) error) bool {
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
@@ -924,25 +924,26 @@ func (s *service) withFileScope(w http.ResponseWriter, r *http.Request, cc *Comp
|
||||
}
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
sc, err := parentFileScope(ctx, tx, r, cc)
|
||||
sc, err := fr.scope(ctx, tx, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(ctx, tx, sc)
|
||||
})
|
||||
if err != nil {
|
||||
s.writeFileError(w, r, cc.files[r.PathValue("field")], nil, err)
|
||||
s.writeFileError(w, r, fr.field(r), nil, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// requireSessionKey answers 422 on session_key when the request has no
|
||||
// valid X-Session-Key.
|
||||
func requireSessionKey(w http.ResponseWriter, r *http.Request) bool {
|
||||
_, ok, err := sessionKeyFrom(r)
|
||||
// requireKey answers 422 when the request has no valid file session key:
|
||||
// X-Session-Key on a record's file routes, X-Child-Session-Key on a
|
||||
// relation child's.
|
||||
func requireKey(w http.ResponseWriter, r *http.Request, fr fileRoute) bool {
|
||||
_, ok, err := fr.keyFrom(r)
|
||||
if err == nil && !ok {
|
||||
err = &ValidationError{Details: map[string]any{"session_key": []string{"The session key field is required."}}}
|
||||
err = &ValidationError{Details: map[string]any{fr.keyName: []string{"The " + strings.ReplaceAll(fr.keyName, "_", " ") + " field is required."}}}
|
||||
}
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
@@ -951,6 +952,132 @@ func requireSessionKey(w http.ResponseWriter, r *http.Request) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// fileRoute is the target of a file route: a record's own fileupload fields
|
||||
// (the record file routes) or the manage form fields of a relation child
|
||||
// (the child file routes, D-17). The handlers are shared; the route decides
|
||||
// which fields exist, which header carries the file session key and how the
|
||||
// owner is scoped.
|
||||
type fileRoute struct {
|
||||
files map[string]*compiledFile
|
||||
keyFrom func(*http.Request) (string, bool, error)
|
||||
keyName string
|
||||
scope func(ctx context.Context, tx *gorm.DB, r *http.Request) (*fileScope, error)
|
||||
}
|
||||
|
||||
// field is the route's fileupload field, or nil.
|
||||
func (fr fileRoute) field(r *http.Request) *compiledFile { return fr.files[r.PathValue("field")] }
|
||||
|
||||
// parentFiles is the file route of the record's own fileupload fields.
|
||||
func parentFiles(cc *CompiledController) fileRoute {
|
||||
return fileRoute{
|
||||
files: cc.files,
|
||||
keyFrom: sessionKeyFrom,
|
||||
keyName: "session_key",
|
||||
scope: func(ctx context.Context, tx *gorm.DB, r *http.Request) (*fileScope, error) {
|
||||
return parentFileScope(ctx, tx, r, cc)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// childFiles is the file route of a relation child form's fileupload
|
||||
// fields (D-17), after the capability check: child 0 (a child not created
|
||||
// yet) needs the create button, else 404 as for a record's id 0; a saved
|
||||
// child needs the update button to write and update or a view form to
|
||||
// read, else 403. An unknown relation or child form is 404.
|
||||
func (s *service) childFiles(w http.ResponseWriter, r *http.Request, cc *CompiledController, write bool) (fileRoute, bool) {
|
||||
cr, err := relationOf(cc, r.PathValue("name"))
|
||||
if err == nil && cr.child == nil {
|
||||
err = recordNotFound{}
|
||||
}
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return fileRoute{}, false
|
||||
}
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("child")), 10, 64)
|
||||
if err != nil {
|
||||
writeNotFound(w, r)
|
||||
return fileRoute{}, false
|
||||
}
|
||||
childID := uint(n)
|
||||
switch {
|
||||
case childID == 0 && !cr.allows("create"):
|
||||
writeNotFound(w, r)
|
||||
return fileRoute{}, false
|
||||
case childID > 0 && (write && !cr.allows("update") || !write && cr.childForm() == nil):
|
||||
if principal, _ := bouncer.User(r.Context()); principal != nil {
|
||||
s.logAuth(r, "denied", principal.ID)
|
||||
}
|
||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||
return fileRoute{}, false
|
||||
}
|
||||
return fileRoute{
|
||||
files: cr.child.files,
|
||||
keyFrom: childSessionKeyFrom,
|
||||
keyName: "child_session_key",
|
||||
scope: func(ctx context.Context, tx *gorm.DB, r *http.Request) (*fileScope, error) {
|
||||
return childFileScope(ctx, tx, r, cc, cr, childID)
|
||||
},
|
||||
}, true
|
||||
}
|
||||
|
||||
// relationChildFileList and the six handlers below serve the file routes
|
||||
// of a relation child form under .../relations/{name}/records/{child}.
|
||||
func (s *service) relationChildFileList(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
if fr, ok := s.childFiles(w, r, cc, false); ok {
|
||||
s.fileListOn(w, r, cc, fr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) relationChildFileUpload(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
if fr, ok := s.childFiles(w, r, cc, true); ok {
|
||||
s.fileUploadOn(w, r, cc, fr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) relationChildFileUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
if fr, ok := s.childFiles(w, r, cc, true); ok {
|
||||
s.fileUpdateOn(w, r, cc, fr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) relationChildFileRemove(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
if fr, ok := s.childFiles(w, r, cc, true); ok {
|
||||
s.fileRemoveOn(w, r, cc, fr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) relationChildFileReorder(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
if fr, ok := s.childFiles(w, r, cc, true); ok {
|
||||
s.fileReorderOn(w, r, cc, fr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) relationChildFileDownload(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
if fr, ok := s.childFiles(w, r, cc, false); ok {
|
||||
s.serveProtectedFileOn(w, r, cc, fr, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) relationChildFileThumb(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
if fr, ok := s.childFiles(w, r, cc, false); ok {
|
||||
s.serveProtectedFileOn(w, r, cc, fr, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// deleteBlobsAfterCommit removes a deleted file's blob and thumbnails once
|
||||
// the surrounding transaction has committed.
|
||||
func deleteBlobsAfterCommit(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, f attach.File) {
|
||||
@@ -970,41 +1097,43 @@ func deleteBlobsAfterCommit(ctx context.Context, tx *gorm.DB, bucket *blob.Bucke
|
||||
// removal of an attached file to the next save, or cancels a pending upload
|
||||
// at once (its row now, its blob after commit).
|
||||
func (s *service) fileRemove(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
if cc.files[r.PathValue("field")] == nil {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !requireSessionKey(w, r) {
|
||||
return
|
||||
}
|
||||
fileID, err := pathFileID(r)
|
||||
s.protect(w, r, func(cc *CompiledController) { s.fileRemoveOn(w, r, cc, parentFiles(cc)) })
|
||||
}
|
||||
|
||||
func (s *service) fileRemoveOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
|
||||
if fr.field(r) == nil {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !requireKey(w, r, fr) {
|
||||
return
|
||||
}
|
||||
fileID, err := pathFileID(r)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
bucket := s.bucket()
|
||||
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||
f, err := sc.findFile(ctx, tx, fileID, true)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
bucket := s.bucket()
|
||||
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||
f, err := sc.findFile(ctx, tx, fileID, true)
|
||||
if err != nil {
|
||||
cancelled, err := lagoon.DeferredUnbind(ctx, tx, sc.key, sc.file.name, lagoon.DeferredFileType, uitoa(f.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cancelled != nil && f.AttachmentID == "" {
|
||||
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id = ?", f.ID).Delete(&attach.File{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
cancelled, err := lagoon.DeferredUnbind(ctx, tx, sc.key, sc.file.name, lagoon.DeferredFileType, uitoa(f.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cancelled != nil && f.AttachmentID == "" {
|
||||
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id = ?", f.ID).Delete(&attach.File{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
deleteBlobsAfterCommit(ctx, tx, bucket, *f)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if ok {
|
||||
WriteData(w, http.StatusOK, FileMutationResult{Removed: 1}, nil)
|
||||
deleteBlobsAfterCommit(ctx, tx, bucket, *f)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if ok {
|
||||
WriteData(w, http.StatusOK, FileMutationResult{Removed: 1}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// jsonCap is the body cap of the JSON file routes: http.body_limits.
|
||||
@@ -1039,122 +1168,126 @@ func (s *service) decodeStrictBody(w http.ResponseWriter, r *http.Request, dest
|
||||
// title and description at once (WinterCMS's onSaveAttachmentConfig). The
|
||||
// field must declare useCaption.
|
||||
func (s *service) fileUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
cf := cc.files[r.PathValue("field")]
|
||||
if cf == nil {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !cf.field.UseCaption {
|
||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||
return
|
||||
}
|
||||
fileID, err := pathFileID(r)
|
||||
s.protect(w, r, func(cc *CompiledController) { s.fileUpdateOn(w, r, cc, parentFiles(cc)) })
|
||||
}
|
||||
|
||||
func (s *service) fileUpdateOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
|
||||
cf := fr.field(r)
|
||||
if cf == nil {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !cf.field.UseCaption {
|
||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||
return
|
||||
}
|
||||
fileID, err := pathFileID(r)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
var in AdminFileCaptionRequest
|
||||
if err := s.decodeStrictBody(w, r, &in); err != nil {
|
||||
s.writeFileError(w, r, cf, nil, err)
|
||||
return
|
||||
}
|
||||
bucket := s.bucket()
|
||||
var item FileItem
|
||||
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||
f, err := sc.findFile(ctx, tx, fileID, true)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
var in AdminFileCaptionRequest
|
||||
if err := s.decodeStrictBody(w, r, &in); err != nil {
|
||||
s.writeFileError(w, r, cf, nil, err)
|
||||
return
|
||||
updates := map[string]any{}
|
||||
if in.Title != nil {
|
||||
updates["title"] = *in.Title
|
||||
f.Title = in.Title
|
||||
}
|
||||
bucket := s.bucket()
|
||||
var item FileItem
|
||||
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||
f, err := sc.findFile(ctx, tx, fileID, true)
|
||||
if err != nil {
|
||||
if in.Description != nil {
|
||||
updates["description"] = *in.Description
|
||||
f.Description = in.Description
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).Where("id = ?", f.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if in.Title != nil {
|
||||
updates["title"] = *in.Title
|
||||
f.Title = in.Title
|
||||
}
|
||||
if in.Description != nil {
|
||||
updates["description"] = *in.Description
|
||||
f.Description = in.Description
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).Where("id = ?", f.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
item = fileItem(ctx, bucket, cf, f, f.AttachmentID == "")
|
||||
return nil
|
||||
})
|
||||
if ok {
|
||||
WriteData(w, http.StatusOK, item, nil)
|
||||
}
|
||||
item = fileItem(ctx, bucket, cf, f, f.AttachmentID == "")
|
||||
return nil
|
||||
})
|
||||
if ok {
|
||||
WriteData(w, http.StatusOK, item, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// fileReorder serves POST .../{id}/files/{field}/reorder: the submitted ids
|
||||
// must be exactly the field's visible files, and they receive the visible
|
||||
// files' existing sort_order values, ascending, in the submitted order.
|
||||
func (s *service) fileReorder(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
cf := cc.files[r.PathValue("field")]
|
||||
if cf == nil {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
s.protect(w, r, func(cc *CompiledController) { s.fileReorderOn(w, r, cc, parentFiles(cc)) })
|
||||
}
|
||||
|
||||
func (s *service) fileReorderOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
|
||||
cf := fr.field(r)
|
||||
if cf == nil {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !cf.relation.Many {
|
||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||
return
|
||||
}
|
||||
var in AdminIDsRequest
|
||||
if err := s.decodeStrictBody(w, r, &in); err != nil {
|
||||
s.writeFileError(w, r, cf, nil, err)
|
||||
return
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cf.relation.Many {
|
||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||
return
|
||||
byID := make(map[uint]int, len(files))
|
||||
orders := make([]int, len(files))
|
||||
for i, f := range files {
|
||||
byID[f.ID] = i
|
||||
orders[i] = f.SortOrder
|
||||
}
|
||||
var in AdminIDsRequest
|
||||
if err := s.decodeStrictBody(w, r, &in); err != nil {
|
||||
s.writeFileError(w, r, cf, nil, err)
|
||||
return
|
||||
seen := map[uint]bool{}
|
||||
valid := len(in.IDs) == len(files)
|
||||
for _, raw := range in.IDs {
|
||||
id := uint(raw)
|
||||
if _, known := byID[id]; !known || seen[id] || uint64(id) != raw {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
bucket := s.bucket()
|
||||
var items []FileItem
|
||||
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||
files, _, err := sc.visibleFiles(tx)
|
||||
if err != nil {
|
||||
if !valid {
|
||||
return &ValidationError{Details: map[string]any{"ids": []string{"The ids field must list every file of the field exactly once."}}}
|
||||
}
|
||||
slices.Sort(orders)
|
||||
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
|
||||
for i, raw := range in.IDs {
|
||||
if err := q.Model(&attach.File{}).Where("id = ?", uint(raw)).Update("sort_order", orders[i]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
byID := make(map[uint]int, len(files))
|
||||
orders := make([]int, len(files))
|
||||
for i, f := range files {
|
||||
byID[f.ID] = i
|
||||
orders[i] = f.SortOrder
|
||||
}
|
||||
seen := map[uint]bool{}
|
||||
valid := len(in.IDs) == len(files)
|
||||
for _, raw := range in.IDs {
|
||||
id := uint(raw)
|
||||
if _, known := byID[id]; !known || seen[id] || uint64(id) != raw {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
if !valid {
|
||||
return &ValidationError{Details: map[string]any{"ids": []string{"The ids field must list every file of the field exactly once."}}}
|
||||
}
|
||||
slices.Sort(orders)
|
||||
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
|
||||
for i, raw := range in.IDs {
|
||||
if err := q.Model(&attach.File{}).Where("id = ?", uint(raw)).Update("sort_order", orders[i]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
files, pending, err := sc.visibleFiles(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items = make([]FileItem, 0, len(files))
|
||||
for i := range files {
|
||||
items = append(items, fileItem(ctx, bucket, cf, &files[i], pending[files[i].ID]))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if ok {
|
||||
WriteData(w, http.StatusOK, items, nil)
|
||||
}
|
||||
files, pending, err := sc.visibleFiles(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items = make([]FileItem, 0, len(files))
|
||||
for i := range files {
|
||||
items = append(items, fileItem(ctx, bucket, cf, &files[i], pending[files[i].ID]))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if ok {
|
||||
WriteData(w, http.StatusOK, items, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// fileDownload serves GET .../{id}/files/{field}/{file}/download.
|
||||
@@ -1175,83 +1308,85 @@ func (s *service) fileThumb(w http.ResponseWriter, r *http.Request) {
|
||||
// application/octet-stream attachment. Every response is nosniff, private
|
||||
// and no-store, under a sandboxing CSP.
|
||||
func (s *service) serveProtectedFile(w http.ResponseWriter, r *http.Request, thumb bool) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
cf := cc.files[r.PathValue("field")]
|
||||
if cf == nil {
|
||||
s.protect(w, r, func(cc *CompiledController) { s.serveProtectedFileOn(w, r, cc, parentFiles(cc), thumb) })
|
||||
}
|
||||
|
||||
func (s *service) serveProtectedFileOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute, thumb bool) {
|
||||
cf := fr.field(r)
|
||||
if cf == nil {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
fileID, err := pathFileID(r)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
bucket := s.bucket()
|
||||
if bucket == nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: protected file route without a storage bucket", "controller", controllerID(cc))
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
var f *attach.File
|
||||
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||
found, err := sc.findFile(ctx, tx, fileID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found.Public() {
|
||||
return recordNotFound{}
|
||||
}
|
||||
f = found
|
||||
return nil
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
key := attach.BlobKey(f.DiskName)
|
||||
contentType := f.ContentType
|
||||
if thumb {
|
||||
if !slices.Contains(attach.AllowedImageMIMEs, f.ContentType) {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
fileID, err := pathFileID(r)
|
||||
key, err = f.ThumbKey(ctx, bucket, cf.thumbW, cf.thumbH, cf.thumbMode)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
bucket := s.bucket()
|
||||
if bucket == nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: protected file route without a storage bucket", "controller", controllerID(cc))
|
||||
slog.Default().ErrorContext(ctx, "cabana: protected thumbnail failed", "file_id", f.ID, "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
var f *attach.File
|
||||
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||
found, err := sc.findFile(ctx, tx, fileID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found.Public() {
|
||||
return recordNotFound{}
|
||||
}
|
||||
f = found
|
||||
return nil
|
||||
})
|
||||
if !ok {
|
||||
contentType = ""
|
||||
}
|
||||
reader, err := bucket.NewReader(ctx, key, nil)
|
||||
if err != nil {
|
||||
if gcerrors.Code(err) == gcerrors.NotFound {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
key := attach.BlobKey(f.DiskName)
|
||||
contentType := f.ContentType
|
||||
if thumb {
|
||||
if !slices.Contains(attach.AllowedImageMIMEs, f.ContentType) {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
key, err = f.ThumbKey(ctx, bucket, cf.thumbW, cf.thumbH, cf.thumbMode)
|
||||
if err != nil {
|
||||
slog.Default().ErrorContext(ctx, "cabana: protected thumbnail failed", "file_id", f.ID, "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
contentType = ""
|
||||
}
|
||||
reader, err := bucket.NewReader(ctx, key, nil)
|
||||
if err != nil {
|
||||
if gcerrors.Code(err) == gcerrors.NotFound {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Default().ErrorContext(ctx, "cabana: protected file read failed", "file_id", f.ID, "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
if contentType == "" {
|
||||
contentType = reader.ContentType()
|
||||
}
|
||||
contentType = strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Cache-Control", "private, no-store")
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
if slices.Contains(attach.AllowedImageMIMEs, contentType) {
|
||||
h.Set("Content-Type", contentType)
|
||||
} else {
|
||||
h.Set("Content-Type", "application/octet-stream")
|
||||
h.Set("Content-Disposition", "attachment; filename*=UTF-8''"+rfc5987(f.FileName))
|
||||
}
|
||||
h.Set("Content-Length", strconv.FormatInt(reader.Size(), 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, reader)
|
||||
})
|
||||
slog.Default().ErrorContext(ctx, "cabana: protected file read failed", "file_id", f.ID, "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
if contentType == "" {
|
||||
contentType = reader.ContentType()
|
||||
}
|
||||
contentType = strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Cache-Control", "private, no-store")
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
if slices.Contains(attach.AllowedImageMIMEs, contentType) {
|
||||
h.Set("Content-Type", contentType)
|
||||
} else {
|
||||
h.Set("Content-Type", "application/octet-stream")
|
||||
h.Set("Content-Disposition", "attachment; filename*=UTF-8''"+rfc5987(f.FileName))
|
||||
}
|
||||
h.Set("Content-Length", strconv.FormatInt(reader.Size(), 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, reader)
|
||||
}
|
||||
|
||||
// rfc5987 percent-encodes a file name for a filename* parameter: only
|
||||
|
||||
@@ -108,6 +108,13 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
if err := checkFileLimits(reg, uploadBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var gdb *gorm.DB
|
||||
if app != nil {
|
||||
gdb, _ = app.Lookup[*gorm.DB]()
|
||||
}
|
||||
if err := checkDeferredModels(reg, plugins, gdb); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compileContributions(reg, plugins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -291,6 +298,23 @@ func (s *service) mount(r pact.Router) {
|
||||
constrainChild(g)
|
||||
g.Put("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", requireAjax(s.relationPivotUpdate))
|
||||
constrainChild(g)
|
||||
// File routes of a relation child form (D-17): {child} 0 is the
|
||||
// child being created in the X-Child-Session-Key session.
|
||||
const childFiles = "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}"
|
||||
g.Get(childFiles, s.relationChildFileList)
|
||||
constrainChildFile(g)
|
||||
g.Post(childFiles, requireAjax(s.relationChildFileUpload))
|
||||
constrainChildFile(g)
|
||||
g.Post(childFiles+"/reorder", requireAjax(s.relationChildFileReorder))
|
||||
constrainChildFile(g)
|
||||
g.Put(childFiles+"/{file}", requireAjax(s.relationChildFileUpdate))
|
||||
constrainChildFileID(g)
|
||||
g.Delete(childFiles+"/{file}", requireAjax(s.relationChildFileRemove))
|
||||
constrainChildFileID(g)
|
||||
g.Get(childFiles+"/{file}/download", s.relationChildFileDownload)
|
||||
constrainChildFileID(g)
|
||||
g.Get(childFiles+"/{file}/thumb", s.relationChildFileThumb)
|
||||
constrainChildFileID(g)
|
||||
// File routes of `type: fileupload` fields (D-09). {id} 0 is the
|
||||
// record being created in the X-Session-Key session.
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
|
||||
@@ -335,6 +359,16 @@ func constrainChild(g pact.Router) {
|
||||
g.Where("child", "[0-9]+")
|
||||
}
|
||||
|
||||
func constrainChildFile(g pact.Router) {
|
||||
constrainChild(g)
|
||||
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
|
||||
}
|
||||
|
||||
func constrainChildFileID(g pact.Router) {
|
||||
constrainChildFile(g)
|
||||
g.Where("file", "[0-9]+")
|
||||
}
|
||||
|
||||
func constrainFile(g pact.Router) {
|
||||
constrainController(g)
|
||||
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
|
||||
@@ -506,9 +540,8 @@ func (s *service) relationList(w http.ResponseWriter, r *http.Request, candidate
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
svc, err := s.relations()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
svc, ok := s.relationsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var result *RelationResult
|
||||
@@ -559,9 +592,8 @@ func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
svc, err := s.relations()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
svc, ok := s.relationsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var result RelationMutationResult
|
||||
@@ -601,6 +633,25 @@ func (s *service) relations() (RelationService, error) {
|
||||
return RelationService{DB: db, bucket: s.bucket(), tr: s.translator()}, nil
|
||||
}
|
||||
|
||||
// relationsFor is the relation service of one request, carrying its
|
||||
// X-Session-Key: with it, record id 0 is the record being created in that
|
||||
// session. A malformed key is a 422; it writes the response and returns
|
||||
// false on failure.
|
||||
func (s *service) relationsFor(w http.ResponseWriter, r *http.Request) (RelationService, bool) {
|
||||
key, _, err := sessionKeyFrom(r)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return RelationService{}, false
|
||||
}
|
||||
svc, err := s.relations()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return RelationService{}, false
|
||||
}
|
||||
svc.SessionKey = key
|
||||
return svc, true
|
||||
}
|
||||
|
||||
func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
if cc.Form == nil {
|
||||
|
||||
@@ -236,6 +236,36 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d", e.gadgetID, e.partID), nil, true)
|
||||
}, into[cabana.RecordEnvelope](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}", 201, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.childUpload(t, e.gadgetID, e.partID, "images", "part.png", conformPNG(t), e.childKey)
|
||||
e.partFileID = dataID(t, rec.Body.Bytes())
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.FileItem]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.sendWith(t, http.MethodGet, e.childFilePath(""), nil, "", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
|
||||
var body cabana.Envelope[[]cabana.FileItem]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body.Data) != 1 || !body.Data[0].Pending || body.Data[0].URL != "" {
|
||||
t.Fatalf("pending child file list = %s (%v)", rec.Body.String(), err)
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.Envelope[[]cabana.FileItem]](), nil},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(map[string]any{"ids": []uint{e.partFileID}})
|
||||
return e.sendWith(t, http.MethodPost, e.childFilePath("/reorder"), body, "application/json", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
|
||||
}, into[cabana.Envelope[[]cabana.FileItem]](), nil},
|
||||
{"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(map[string]any{"title": "Part " + e.stamp})
|
||||
return e.sendWith(t, http.MethodPut, e.childFilePath(fmt.Sprintf("/%d", e.partFileID)), body, "application/json", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
|
||||
}, into[cabana.Envelope[cabana.FileItem]](), nil},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.sendWith(t, http.MethodGet, e.childFilePath(fmt.Sprintf("/%d/download", e.partFileID)), nil, "", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
|
||||
}, nil, binaryFile("image/png")},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.sendWith(t, http.MethodGet, e.childFilePath(fmt.Sprintf("/%d/thumb", e.partFileID)), nil, "", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
|
||||
}, nil, binaryFile("image/png")},
|
||||
{"DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.sendWith(t, http.MethodDelete, e.childFilePath(fmt.Sprintf("/%d", e.partFileID)), nil, "", map[string]string{cabana.ChildSessionKeyHeader: e.childKey})
|
||||
}, into[cabana.Envelope[cabana.FileMutationResult]](), nil},
|
||||
{"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d", e.gadgetID, e.partID), map[string]any{"label": "part-" + e.stamp + "-renamed"}, true)
|
||||
}, into[cabana.RecordEnvelope](), nil},
|
||||
@@ -362,6 +392,8 @@ type conformEnv struct {
|
||||
memberID uint
|
||||
gadgetID uint
|
||||
partID uint
|
||||
partFileID uint
|
||||
childKey string
|
||||
}
|
||||
|
||||
// sendWith sends a raw body with extra headers through the assembled router.
|
||||
@@ -402,6 +434,35 @@ func (e *conformEnv) upload(t *testing.T, id uint, field, name string, data []by
|
||||
return e.sendWith(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/files/%s", id, field), buf.Bytes(), mw.FormDataContentType(), headers)
|
||||
}
|
||||
|
||||
// childFilePath is the conformance part's file route under the gadget's
|
||||
// parts relation, with rest appended.
|
||||
func (e *conformEnv) childFilePath(rest string) string {
|
||||
return fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d/files/images%s", e.gadgetID, e.partID, rest)
|
||||
}
|
||||
|
||||
// childUpload posts one multipart file_data part to a part's file field
|
||||
// under a gadget's parts relation (child 0 is the part being created).
|
||||
func (e *conformEnv) childUpload(t *testing.T, gadget, part uint, field, name string, data []byte, childKey string, extra ...string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
w, err := mw.CreateFormFile("file_data", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := map[string]string{cabana.ChildSessionKeyHeader: childKey}
|
||||
if len(extra) > 0 {
|
||||
headers[cabana.SessionKeyHeader] = extra[0]
|
||||
}
|
||||
return e.sendWith(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d/files/%s", gadget, part, field), buf.Bytes(), mw.FormDataContentType(), headers)
|
||||
}
|
||||
|
||||
// conformPNG is a small valid PNG.
|
||||
func conformPNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
@@ -504,10 +565,10 @@ func newConformEnv(t *testing.T) *conformEnv {
|
||||
}
|
||||
// Gadget ids restart with the recreated table: drop the files and
|
||||
// bindings an earlier run left for them.
|
||||
if err := gdb.Exec(`DELETE FROM system_files WHERE attachment_type = 'acme.conform.gadget' OR attachment_id IS NULL OR attachment_id = ''`).Error; err != nil {
|
||||
if err := gdb.Exec(`DELETE FROM system_files WHERE attachment_type IN ('acme.conform.gadget', 'acme.conform.part') OR attachment_id IS NULL OR attachment_id = ''`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Exec(`DELETE FROM deferred_bindings WHERE master_type = 'acme.conform.gadget'`).Error; err != nil {
|
||||
if err := gdb.Exec(`DELETE FROM deferred_bindings WHERE master_type IN ('acme.conform.gadget', 'acme.conform.part')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stamp := fmt.Sprintf("c%d", time.Now().UnixNano())
|
||||
@@ -552,7 +613,7 @@ func newConformEnv(t *testing.T) *conformEnv {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &conformEnv{h: h, db: gdb, bucket: bucket, login: login, stamp: stamp, sessionKey: newSessionKey(t), groupID: group.ID, memberID: member.ID}
|
||||
return &conformEnv{h: h, db: gdb, bucket: bucket, login: login, stamp: stamp, sessionKey: newSessionKey(t), childKey: newSessionKey(t), groupID: group.ID, memberID: member.ID}
|
||||
}
|
||||
|
||||
type conformGadget struct {
|
||||
@@ -622,9 +683,15 @@ type conformPart struct {
|
||||
}
|
||||
|
||||
func (conformPart) TableName() string { return "cabana_conform_parts" }
|
||||
func (conformPart) MorphName() string { return "acme.conform.part" }
|
||||
func (conformPart) Fillable() []string { return []string{"label"} }
|
||||
func (conformPart) Rules() map[string]string { return map[string]string{"label": "required"} }
|
||||
|
||||
// AttachRelations declares the part form's protected attachMany images.
|
||||
func (conformPart) AttachRelations() []attach.Relation {
|
||||
return []attach.Relation{{Name: "images", Many: true}}
|
||||
}
|
||||
|
||||
type conformSettings struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Enabled bool `gorm:"column:enabled"`
|
||||
@@ -643,6 +710,11 @@ func (conformPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p conformPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{conformController{stamp: p.stamp}}
|
||||
}
|
||||
|
||||
// Models lists the models deferred:purge may delete; the parts relation
|
||||
// creates parts under deferral, so cabana's boot requires conformPart here.
|
||||
func (conformPlugin) Models() []any { return []any{&conformGadget{}, &conformPart{}} }
|
||||
|
||||
func (conformPlugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{{Code: "acme.conform.access", Roles: []string{"developer"}}}
|
||||
}
|
||||
@@ -670,6 +742,17 @@ func (conformController) AdminRelationContracts() []cabana.RelationContract {
|
||||
Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
|
||||
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
|
||||
HookPivotColumns: []string{"stamp"},
|
||||
// A gadget named exclude-<id>-... may not link member <id>, so a
|
||||
// link the create form deferred can turn ineligible at save.
|
||||
ExcludedRelatedIDs: func(parent any) ([]uint, error) {
|
||||
var id uint
|
||||
if g, ok := parent.(*conformGadget); ok && g != nil {
|
||||
if _, err := fmt.Sscanf(g.Name, "exclude-%d-", &id); err == nil {
|
||||
return []uint{id}, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}, {
|
||||
Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &conformPart{} },
|
||||
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
|
||||
@@ -852,7 +935,6 @@ parts:
|
||||
members:
|
||||
type: relation-manager
|
||||
relation: members
|
||||
context: [update]
|
||||
parts:
|
||||
type: relation-manager
|
||||
relation: parts
|
||||
@@ -902,6 +984,11 @@ parts:
|
||||
label: Label
|
||||
type: text
|
||||
required: true
|
||||
images:
|
||||
label: Images
|
||||
type: fileupload
|
||||
mode: image
|
||||
useCaption: true
|
||||
`),
|
||||
"models/settings/fields.yaml": file(`fields:
|
||||
enabled:
|
||||
|
||||
@@ -90,6 +90,7 @@ func TestPhase10Coverage(t *testing.T) {
|
||||
want := []string{
|
||||
"DELETE /{vendor}/{plugin}/{controller}/{id}",
|
||||
"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
||||
"DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
|
||||
"POST /auth/login",
|
||||
"POST /auth/logout",
|
||||
"POST /auth/refresh",
|
||||
@@ -102,15 +103,18 @@ func TestPhase10Coverage(t *testing.T) {
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
||||
"PUT /settings/{code}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
|
||||
}
|
||||
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 19 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 23 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||
}
|
||||
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
||||
// nested pattern serving field options, filter options and relation lists.
|
||||
|
||||
@@ -69,9 +69,10 @@ func TestPhase10CSRF(t *testing.T) {
|
||||
// refresh, logout, settings put, create, bulk-delete, widget action,
|
||||
// toolbar action, update, delete, link, unlink, file upload, file
|
||||
// reorder, file caption, file remove, relation child create, update
|
||||
// and delete, pivot update
|
||||
if unsafe != 19 {
|
||||
t.Fatalf("walked %d state-changing routes, want 19: %v", unsafe, router.order)
|
||||
// and delete, pivot update, child file upload, reorder, caption and
|
||||
// remove
|
||||
if unsafe != 23 {
|
||||
t.Fatalf("walked %d state-changing routes, want 23: %v", unsafe, router.order)
|
||||
}
|
||||
|
||||
loginHandler := router.handlers[login]
|
||||
|
||||
@@ -2,10 +2,14 @@ package cabana
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type controllerRef struct {
|
||||
@@ -332,3 +336,83 @@ func (r *Registry) rolePermissions(role string) map[string]bool {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkDeferredModels stops boot when a deferrable relation that declares
|
||||
// create points at a related model no activated plugin lists in
|
||||
// pact.HasModels Models(): deferred:purge resolves the slave type of an
|
||||
// abandoned child through those models and could not delete it (RESEARCH
|
||||
// Pitfall 9). A listed model of the same Go type matches; with a database,
|
||||
// a listed model of the same morph type matches too.
|
||||
func checkDeferredModels(reg *Registry, plugins []party.Plugin, gdb *gorm.DB) error {
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
var models []any
|
||||
for _, p := range plugins {
|
||||
if hm, ok := p.(pact.HasModels); ok && hm != nil {
|
||||
for _, m := range hm.Models() {
|
||||
if m != nil {
|
||||
models = append(models, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(reg.byID))
|
||||
for id := range reg.byID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
cc := reg.byID[id]
|
||||
names := make([]string, 0, len(cc.Relations))
|
||||
for name := range cc.Relations {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
cr := cc.Relations[name]
|
||||
if !cr.deferrable || !cr.allows("create") {
|
||||
continue
|
||||
}
|
||||
related := cr.Contract.NewRelated()
|
||||
if !modelListed(models, related, gdb) {
|
||||
morph := tableName(related)
|
||||
if gdb != nil {
|
||||
if m, err := lagoon.MorphType(gdb, related); err == nil {
|
||||
morph = m
|
||||
}
|
||||
} else if owner, ok := related.(interface{ MorphName() string }); ok {
|
||||
morph = owner.MorphName()
|
||||
}
|
||||
return fmt.Errorf("cabana: admin controller %s/%s: relation %s creates %s records under deferral, but no activated plugin lists that model in Models(), so deferred:purge could not remove abandoned ones", cc.PluginID, id, name, morph)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func modelListed(models []any, related any, gdb *gorm.DB) bool {
|
||||
want := reflect.TypeOf(related)
|
||||
for want.Kind() == reflect.Pointer {
|
||||
want = want.Elem()
|
||||
}
|
||||
var morph string
|
||||
if gdb != nil {
|
||||
morph, _ = lagoon.MorphType(gdb, related)
|
||||
}
|
||||
for _, m := range models {
|
||||
t := reflect.TypeOf(m)
|
||||
for t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t == want {
|
||||
return true
|
||||
}
|
||||
if morph != "" {
|
||||
if got, err := lagoon.MorphType(gdb, m); err == nil && got == morph {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -179,6 +179,11 @@ type RelationMutationResult struct {
|
||||
// RelationService executes compiled relation reads and writes.
|
||||
type RelationService struct {
|
||||
DB *gorm.DB
|
||||
// SessionKey is the parent form's session key (X-Session-Key). With
|
||||
// it, owner id 0 is the record being created in that session: relation
|
||||
// work on it is held in deferred_bindings until the record's first save
|
||||
// (D-03). Without it, id 0 is not found.
|
||||
SessionKey string
|
||||
|
||||
// bucket deletes the blobs of child files a save removes, after commit;
|
||||
// tr localizes date bound messages. Both may be nil.
|
||||
@@ -609,14 +614,11 @@ func (s RelationService) query(ctx context.Context, cc *CompiledController, rela
|
||||
}
|
||||
var result *RelationResult
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
parent, err := newWritableModel(cc)
|
||||
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil {
|
||||
return err
|
||||
}
|
||||
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, candidates)
|
||||
q, target, err := relationQuery(ctx, tx, cc, cr, parent, candidates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -877,11 +879,15 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
|
||||
var result RelationMutationResult
|
||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
parent, err := s.loadParent(ctx, tx, cc, ownerID)
|
||||
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.Linked, err = s.linkRelated(ctx, tx, cc, cr, parent.model, ids, in.Pivot, "ids")
|
||||
if parent.unsaved() {
|
||||
result.Linked, err = s.linkDeferred(ctx, tx, cc, cr, parent, ids, in.Pivot)
|
||||
return err
|
||||
}
|
||||
result.Linked, err = s.linkRelated(ctx, tx, cc, cr, parent.model, ids, in.Pivot, relationInvalid("ids", "contains an ineligible target"))
|
||||
return err
|
||||
})
|
||||
return result, err
|
||||
@@ -890,9 +896,9 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
|
||||
// linkRelated links ids to the saved parent inside tx (the shared link
|
||||
// path of the link route and of the deferred commit). Eligibility is the
|
||||
// candidate query: RelationExtendManageQuery, ExcludedRelatedIDs, not linked
|
||||
// yet, and not a child created in a pending session. An ineligible id is a
|
||||
// 422 on errField.
|
||||
func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, ids []uint, pivot map[string]any, errField string) (int, error) {
|
||||
// yet, and not a child created in a pending session. An ineligible id
|
||||
// fails the link with the ineligible error.
|
||||
func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, ids []uint, pivot map[string]any, ineligible error) (int, error) {
|
||||
ownerPK := pkUint(parent)
|
||||
var pending []uint
|
||||
var err error
|
||||
@@ -914,7 +920,7 @@ func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *Compi
|
||||
return 0, err
|
||||
}
|
||||
if holder.Elem().Len() != len(pending) {
|
||||
return 0, relationInvalid(errField, "contains an ineligible target")
|
||||
return 0, ineligible
|
||||
}
|
||||
linked := 0
|
||||
for i := 0; i < holder.Elem().Len(); i++ {
|
||||
@@ -944,6 +950,109 @@ func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *Compi
|
||||
return linked, nil
|
||||
}
|
||||
|
||||
// boundIDs are the ids among ids that the unsaved parent's session binds.
|
||||
func boundIDs(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, ids []uint) ([]uint, error) {
|
||||
target := cr.Contract.NewRelated()
|
||||
pk := primaryColumn(target)
|
||||
column := quotedIdent(tx, tableName(target)) + "." + quotedIdent(tx, pk)
|
||||
var bound []uint
|
||||
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(target).
|
||||
Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: pk}, Values: uintValues(ids)}).
|
||||
Where("CAST("+column+" AS TEXT) IN (?)", parent.boundSlaves(tx, cr)).
|
||||
Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: pk}}).
|
||||
Pluck(pk, &bound).Error
|
||||
return bound, err
|
||||
}
|
||||
|
||||
// linkDeferred links ids to an unsaved parent (D-03): each eligible
|
||||
// candidate (checked now against the zero-key parent, and again by the
|
||||
// parent's first save) is bound to the session, with the whitelisted and
|
||||
// validated pivot values in the bind's envelope. Ids already bound are
|
||||
// skipped.
|
||||
func (s RelationService) linkDeferred(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, ids []uint, pivot map[string]any) (int, error) {
|
||||
bound, err := boundIDs(ctx, tx, cr, parent, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pending := make([]uint, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if !slices.Contains(bound, id) {
|
||||
pending = append(pending, id)
|
||||
}
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
q, target, err := relationQuery(ctx, tx, cc, cr, parent, true)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var eligible []uint
|
||||
pk := primaryColumn(target)
|
||||
err = q.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: pk}, Values: uintValues(pending)}).
|
||||
Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: pk}}).
|
||||
Pluck(pk, &eligible).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(eligible) != len(pending) {
|
||||
return 0, relationInvalid("ids", "contains an ineligible target")
|
||||
}
|
||||
var env *lagoon.DeferredEnvelope
|
||||
if pivot != nil {
|
||||
if err := s.fillPivot(ctx, tx, cr, cr.Contract.NewPivot(), pivot); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
env = &lagoon.DeferredEnvelope{Pivot: ProjectWritableFields(cr.pivot, pivot)}
|
||||
}
|
||||
for _, id := range eligible {
|
||||
if err := lagoon.DeferredBind(ctx, tx, *parent.key, cr.Contract.Name, parent.morph, uitoa(id), env); err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
return len(eligible), nil
|
||||
}
|
||||
|
||||
// unlinkDeferred cancels the unsaved parent's pending binds of ids; a child
|
||||
// the session created is deleted through its model. Ids not bound in the
|
||||
// session are ignored.
|
||||
func (s RelationService) unlinkDeferred(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, ids []uint) (int, error) {
|
||||
bound, err := boundIDs(ctx, tx, cr, parent, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range bound {
|
||||
cancelled, err := lagoon.DeferredUnbind(ctx, tx, *parent.key, cr.Contract.Name, parent.morph, uitoa(id))
|
||||
if err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
if cancelled == nil {
|
||||
continue
|
||||
}
|
||||
env, err := cancelled.Envelope()
|
||||
if err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
if !env.Created {
|
||||
continue
|
||||
}
|
||||
child := cr.Contract.NewRelated()
|
||||
err = tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where(clause.Eq{Column: clause.Column{Name: primaryColumn(child)}, Value: castPK(child, id)}).Take(child).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Delete(child).Error; err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
return len(bound), nil
|
||||
}
|
||||
|
||||
// pendingChildIDs drops the ids a hasMany parent already owns.
|
||||
func pendingChildIDs(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, ownerID uint, ids []uint) ([]uint, error) {
|
||||
target := cr.Contract.NewRelated()
|
||||
@@ -1087,10 +1196,14 @@ func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, rel
|
||||
var result RelationMutationResult
|
||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
parent, err := s.loadParent(ctx, tx, cc, ownerID)
|
||||
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parent.unsaved() {
|
||||
result.Removed, err = s.unlinkDeferred(ctx, tx, cc, cr, parent, ids)
|
||||
return err
|
||||
}
|
||||
result.Removed, err = s.unlinkRelated(ctx, tx, cc, cr, parent.model, ids)
|
||||
return err
|
||||
})
|
||||
|
||||
@@ -16,28 +16,82 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// relationParent is the parent record of a relation route, loaded through
|
||||
// FormExtendQuery.
|
||||
// relationParent is the parent record of a relation route: a saved record
|
||||
// loaded through FormExtendQuery, or (id 0) the record being created in the
|
||||
// admin's form session, whose relation work is held against key.
|
||||
type relationParent struct {
|
||||
model any
|
||||
id uint
|
||||
// key is the unsaved parent's deferred-binding key (D-03), nil for a
|
||||
// saved parent; morph is the related model's morph type, the slave type
|
||||
// of the relation's bindings.
|
||||
key *lagoon.DeferredKey
|
||||
morph string
|
||||
}
|
||||
|
||||
// unsaved reports whether the parent is the record being created.
|
||||
func (p *relationParent) unsaved() bool { return p != nil && p.key != nil }
|
||||
|
||||
// loadParent loads the parent record of a relation route through
|
||||
// loadRecord (FormExtendQuery, FOR UPDATE). A missing or hidden parent, and
|
||||
// id 0, are recordNotFound.
|
||||
func (s RelationService) loadParent(ctx context.Context, tx *gorm.DB, cc *CompiledController, ownerID uint) (*relationParent, error) {
|
||||
// loadRecord (FormExtendQuery, FOR UPDATE). Id 0 is the record being
|
||||
// created in the s.SessionKey session: it needs a deferrable relation, the
|
||||
// controller's create operation, the relation-manager field in the create
|
||||
// context and a backend admin; the parent is then a fresh zero-key record.
|
||||
// A missing or hidden parent, and id 0 without all of that, are
|
||||
// recordNotFound.
|
||||
func (s RelationService) loadParent(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, ownerID uint) (*relationParent, error) {
|
||||
parent, err := newWritableModel(cc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ownerID == 0 {
|
||||
morph, err := lagoon.MorphType(tx, cr.Contract.NewRelated())
|
||||
if err != nil {
|
||||
return nil, lifecycleFailure(cc, err)
|
||||
}
|
||||
if ownerID > 0 {
|
||||
if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &relationParent{model: parent, id: ownerID, morph: morph}, nil
|
||||
}
|
||||
if s.SessionKey == "" || !cr.deferrable || !cc.operationDeclared("create") || !contextAllows(cc, cr.fieldName, "create") {
|
||||
return nil, recordNotFound{}
|
||||
}
|
||||
if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil {
|
||||
return nil, err
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if principal == nil || !principal.Backend || principal.ID == 0 {
|
||||
return nil, recordNotFound{}
|
||||
}
|
||||
return &relationParent{model: parent, id: ownerID}, nil
|
||||
master, err := lagoon.MorphType(tx, parent)
|
||||
if err != nil {
|
||||
return nil, lifecycleFailure(cc, err)
|
||||
}
|
||||
key := lagoon.DeferredKey{SessionKey: s.SessionKey, AdminID: principal.ID, MasterType: master}
|
||||
return &relationParent{model: parent, key: &key, morph: morph}, nil
|
||||
}
|
||||
|
||||
// boundSlaves is the subquery of the unsaved parent's pending binds for the
|
||||
// relation (WinterCMS withDeferred): CAST(pk AS TEXT) IN (?).
|
||||
func (p *relationParent) boundSlaves(tx *gorm.DB, cr *CompiledRelation) *gorm.DB {
|
||||
return lagoon.DeferredSlaves(tx, *p.key, cr.Contract.Name, p.morph, true)
|
||||
}
|
||||
|
||||
// relationQuery is relationBaseQuery for a resolved parent. On an unsaved
|
||||
// parent the linked rows are the session's pending binds, and candidates
|
||||
// leave those out.
|
||||
func relationQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, candidates bool) (*gorm.DB, any, error) {
|
||||
if !parent.unsaved() {
|
||||
return relationBaseQuery(ctx, tx, cc, cr, parent.model, candidates)
|
||||
}
|
||||
target := cr.Contract.NewRelated()
|
||||
column := quotedIdent(tx, tableName(target)) + "." + quotedIdent(tx, primaryColumn(target))
|
||||
if !candidates {
|
||||
return tx.WithContext(ctx).Model(target).Where("CAST("+column+" AS TEXT) IN (?)", parent.boundSlaves(tx, cr)), target, nil
|
||||
}
|
||||
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent.model, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return q.Where("CAST("+column+" AS TEXT) NOT IN (?)", parent.boundSlaves(tx, cr)), target, nil
|
||||
}
|
||||
|
||||
// fillChild fills and validates a child of a relation form like the
|
||||
@@ -98,7 +152,7 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
|
||||
var result RecordResult
|
||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
parent, err := s.loadParent(ctx, tx, cc, ownerID)
|
||||
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -106,7 +160,7 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cr.hasMany() {
|
||||
if cr.hasMany() && !parent.unsaved() {
|
||||
if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
@@ -122,11 +176,22 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
|
||||
if err := tx.WithContext(ctx).Create(child).Error; err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
if !cr.hasMany() {
|
||||
switch {
|
||||
case parent.unsaved():
|
||||
// The child exists now, unattached; the parent's first save
|
||||
// attaches it, the purge deletes it if that never happens.
|
||||
env := &lagoon.DeferredEnvelope{Created: true}
|
||||
if err := lagoon.DeferredBind(ctx, tx, *parent.key, cr.Contract.Name, parent.morph, uitoa(pkUint(child)), env); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
case !cr.hasMany():
|
||||
if err := insertPivot(ctx, tx, cc, cr, parent.model, child, nil); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
if err := s.commitChildFiles(ctx, tx, cr, child, "create", in); err != nil {
|
||||
return err
|
||||
}
|
||||
if hook, ok := cc.Controller.(pact.RelationAfterCreate); ok && hook != nil {
|
||||
if err := hook.RelationAfterCreate(ctx, cr.Contract.Name, parent.model, child); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
@@ -144,8 +209,9 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
|
||||
// loadChild finds one child of the parent with a single query that carries
|
||||
// the parent predicate (D-15): on a hasMany the child's ForeignKey must be
|
||||
// the parent's key, on a belongsToMany a pivot row must link the child to
|
||||
// the parent. A child of another parent is recordNotFound (404, never 403).
|
||||
// lock adds FOR UPDATE.
|
||||
// the parent, and on an unsaved parent the child must be bound in the
|
||||
// admin's own session. A child of another parent, or another admin's
|
||||
// pending child, is recordNotFound (404, never 403). lock adds FOR UPDATE.
|
||||
func loadChild(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint, lock bool) (any, error) {
|
||||
if parent == nil || childID == 0 {
|
||||
return nil, recordNotFound{}
|
||||
@@ -155,9 +221,13 @@ func loadChild(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *r
|
||||
pk := clause.Column{Table: table, Name: primaryColumn(child)}
|
||||
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(child).
|
||||
Where(clause.Eq{Column: pk, Value: castPK(child, childID)})
|
||||
if cr.hasMany() {
|
||||
switch {
|
||||
case parent.unsaved():
|
||||
column := quotedIdent(tx, table) + "." + quotedIdent(tx, primaryColumn(child))
|
||||
q = q.Where("CAST("+column+" AS TEXT) IN (?)", parent.boundSlaves(tx, cr))
|
||||
case cr.hasMany():
|
||||
q = q.Where(clause.Eq{Column: clause.Column{Table: table, Name: cr.Contract.ForeignKey}, Value: parent.id})
|
||||
} else {
|
||||
default:
|
||||
linked := "EXISTS (SELECT 1 FROM " + quotedIdent(tx, tableName(cr.Contract.NewPivot())) + " p WHERE p." +
|
||||
quotedIdent(tx, cr.Contract.ParentForeignKey) + " = ? AND p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) +
|
||||
" = " + quotedIdent(tx, table) + "." + quotedIdent(tx, primaryColumn(child)) + ")"
|
||||
@@ -201,7 +271,7 @@ func (s RelationService) ShowChild(ctx context.Context, cc *CompiledController,
|
||||
var result RecordResult
|
||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
parent, err := s.loadParent(ctx, tx, cc, ownerID)
|
||||
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -236,7 +306,7 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
|
||||
var result RecordResult
|
||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
parent, err := s.loadParent(ctx, tx, cc, ownerID)
|
||||
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -255,6 +325,9 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
|
||||
if err := tx.WithContext(ctx).Save(child).Error; err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
if err := s.commitChildFiles(ctx, tx, cr, child, "update", in); err != nil {
|
||||
return err
|
||||
}
|
||||
if hook, ok := cc.Controller.(pact.RelationAfterUpdate); ok && hook != nil {
|
||||
if err := hook.RelationAfterUpdate(ctx, cr.Contract.Name, parent.model, child); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
@@ -290,7 +363,7 @@ func (s RelationService) DeleteChildren(ctx context.Context, cc *CompiledControl
|
||||
var result BulkResult
|
||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
parent, err := s.loadParent(ctx, tx, cc, ownerID)
|
||||
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -303,6 +376,11 @@ func (s RelationService) DeleteChildren(ctx context.Context, cc *CompiledControl
|
||||
children = append(children, child)
|
||||
}
|
||||
for _, child := range children {
|
||||
if parent.unsaved() {
|
||||
if _, err := lagoon.DeferredUnbind(ctx, tx, *parent.key, cr.Contract.Name, parent.morph, uitoa(pkUint(child))); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
if err := s.deleteChild(ctx, tx, cc, cr, parent, child); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -377,10 +455,24 @@ func (s RelationService) ShowPivot(ctx context.Context, cc *CompiledController,
|
||||
var data map[string]any
|
||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
parent, err := s.loadParent(ctx, tx, cc, ownerID)
|
||||
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parent.unsaved() {
|
||||
bind, err := findPendingBind(ctx, tx, cr, parent, childID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := bind.Envelope()
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
row := cr.Contract.NewPivot()
|
||||
_ = lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false)
|
||||
data = pivotRecord(cr, row, childID)
|
||||
return nil
|
||||
}
|
||||
row, err := loadPivotRow(ctx, tx, cr, parent, childID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -408,10 +500,14 @@ func (s RelationService) UpdatePivot(ctx context.Context, cc *CompiledController
|
||||
var data map[string]any
|
||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
parent, err := s.loadParent(ctx, tx, cc, ownerID)
|
||||
parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parent.unsaved() {
|
||||
data, err = s.updatePendingPivot(ctx, tx, cc, cr, parent, childID, values)
|
||||
return err
|
||||
}
|
||||
row, err := loadPivotRow(ctx, tx, cr, parent, childID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -428,6 +524,77 @@ func (s RelationService) UpdatePivot(ctx context.Context, cc *CompiledController
|
||||
return data, err
|
||||
}
|
||||
|
||||
// findPendingBind loads, locked, the unsaved parent's pending bind of one
|
||||
// related record; a missing bind is recordNotFound.
|
||||
func findPendingBind(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint) (*lagoon.DeferredBinding, error) {
|
||||
var row lagoon.DeferredBinding
|
||||
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("session_key = ? AND backend_user_id = ? AND master_type = ? AND master_field = ? AND slave_type = ? AND slave_id = ? AND is_bind",
|
||||
parent.key.SessionKey, parent.key.AdminID, parent.key.MasterType, cr.Contract.Name, parent.morph, uitoa(childID)).
|
||||
Order("id").Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, recordNotFound{}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// pivotFillKeys are the pivot form's writable columns.
|
||||
func pivotFillKeys(cr *CompiledRelation) []string {
|
||||
out := make([]string, 0, len(cr.pivot.Writable))
|
||||
for _, field := range cr.pivot.Writable {
|
||||
out = append(out, field.FillKey)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// updatePendingPivot saves pivot form values on a pending link of an
|
||||
// unsaved parent: they are whitelisted and validated exactly as on a saved
|
||||
// parent and stored in the bind's envelope, which the parent's first save
|
||||
// writes to the pivot row.
|
||||
func (s RelationService) updatePendingPivot(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, childID uint, values map[string]any) (map[string]any, error) {
|
||||
bind, err := findPendingBind(ctx, tx, cr, parent, childID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env, err := bind.Envelope()
|
||||
if err != nil {
|
||||
return nil, lifecycleFailure(cc, err)
|
||||
}
|
||||
row := cr.Contract.NewPivot()
|
||||
_ = lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false)
|
||||
if err := s.fillPivot(ctx, tx, cr, row, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged := map[string]any{}
|
||||
for key, value := range ProjectWritableFields(cr.pivot, env.Pivot) {
|
||||
merged[key] = value
|
||||
}
|
||||
for key, value := range ProjectWritableFields(cr.pivot, values) {
|
||||
merged[key] = value
|
||||
}
|
||||
env.Pivot = merged
|
||||
raw, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return nil, lifecycleFailure(cc, err)
|
||||
}
|
||||
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&lagoon.DeferredBinding{}).
|
||||
Where("id = ?", bind.ID).Update("pivot_data", string(raw)).Error; err != nil {
|
||||
return nil, lifecycleFailure(cc, err)
|
||||
}
|
||||
return pivotRecord(cr, row, childID), nil
|
||||
}
|
||||
|
||||
// commitChildFiles applies the child form's own file bindings (the
|
||||
// X-Child-Session-Key session, in.SessionKey) to the saved child inside the
|
||||
// child's transaction, then rechecks the child's file limits (D-17).
|
||||
func (s RelationService) commitChildFiles(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, child any, op string, in RecordInput) error {
|
||||
crud := CRUDService{DB: s.DB, bucket: s.bucket, tr: s.tr}
|
||||
return crud.commitDeferred(ctx, tx, cr.child, child, op, RecordInput{SessionKey: in.SessionKey})
|
||||
}
|
||||
|
||||
// pivotRecord projects a pivot row through the pivot form, keyed by the
|
||||
// related record's id.
|
||||
func pivotRecord(cr *CompiledRelation, row any, childID uint) map[string]any {
|
||||
@@ -436,6 +603,64 @@ func pivotRecord(cr *CompiledRelation, row any, childID uint) map[string]any {
|
||||
return data
|
||||
}
|
||||
|
||||
// childFileScope resolves a relation child file route (D-17) inside tx:
|
||||
// the relation's manage form field, the parent (a saved record through
|
||||
// FormExtendQuery, or id 0 in the X-Session-Key session) and the child.
|
||||
// Child 0 is the child not created yet and needs X-Child-Session-Key; a
|
||||
// saved child must pass loadChild under the parent. The file key is the
|
||||
// child form's key, the admin and the related model's morph type, so the
|
||||
// child's create or update save commits the files. Anything else is
|
||||
// recordNotFound.
|
||||
func childFileScope(ctx context.Context, tx *gorm.DB, r *http.Request, cc *CompiledController, cr *CompiledRelation, childID uint) (*fileScope, error) {
|
||||
cf := cr.child.files[r.PathValue("field")]
|
||||
if cf == nil {
|
||||
return nil, recordNotFound{}
|
||||
}
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentKey, _, err := sessionKeyFrom(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, hasKey, err := childSessionKeyFrom(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
op := "update"
|
||||
if childID == 0 {
|
||||
op = "create"
|
||||
if !hasKey {
|
||||
return nil, recordNotFound{}
|
||||
}
|
||||
}
|
||||
if !contextAllows(cr.child, cf.name, op) {
|
||||
return nil, recordNotFound{}
|
||||
}
|
||||
parent, err := (RelationService{SessionKey: parentKey}).loadParent(ctx, tx, cc, cr, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc := &fileScope{cc: cr.child, file: cf, ownerID: childID, morph: parent.morph}
|
||||
if hasKey {
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if principal == nil || principal.ID == 0 {
|
||||
return nil, recordNotFound{}
|
||||
}
|
||||
sc.key = lagoon.DeferredKey{SessionKey: key, AdminID: principal.ID, MasterType: parent.morph}
|
||||
sc.hasKey = true
|
||||
}
|
||||
if childID > 0 {
|
||||
child, err := loadChild(ctx, tx, cr, parent, childID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc.owner = child
|
||||
}
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
// relationButton resolves the route's relation and refuses (403) a route
|
||||
// whose toolbar button the view panel does not declare. An unknown relation
|
||||
// is 404. It writes the response and returns nil on refusal.
|
||||
@@ -494,12 +719,16 @@ func (s *service) relationChildCreate(w http.ResponseWriter, r *http.Request) {
|
||||
writeRelationError(w, err)
|
||||
return
|
||||
}
|
||||
svc, err := s.relations()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
svc, ok := s.relationsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rec, err := svc.CreateChild(r.Context(), cc, cr.Contract.Name, id, RecordInput{Body: body})
|
||||
childKey, _, err := childSessionKeyFrom(r)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
rec, err := svc.CreateChild(r.Context(), cc, cr.Contract.Name, id, RecordInput{Body: body, SessionKey: childKey})
|
||||
if err != nil {
|
||||
writeRelationError(w, err)
|
||||
return
|
||||
@@ -563,9 +792,8 @@ func (s *service) relationChildShow(w http.ResponseWriter, r *http.Request) {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
svc, err := s.relations()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
svc, ok := s.relationsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rec, err := svc.ShowChild(r.Context(), cc, cr.Contract.Name, id, child)
|
||||
@@ -594,12 +822,16 @@ func (s *service) relationChildUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
writeRelationError(w, err)
|
||||
return
|
||||
}
|
||||
svc, err := s.relations()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
svc, ok := s.relationsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rec, err := svc.UpdateChild(r.Context(), cc, cr.Contract.Name, id, child, RecordInput{Body: body})
|
||||
childKey, _, err := childSessionKeyFrom(r)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
rec, err := svc.UpdateChild(r.Context(), cc, cr.Contract.Name, id, child, RecordInput{Body: body, SessionKey: childKey})
|
||||
if err != nil {
|
||||
writeRelationError(w, err)
|
||||
return
|
||||
@@ -626,9 +858,8 @@ func (s *service) relationChildDelete(w http.ResponseWriter, r *http.Request) {
|
||||
writeRelationError(w, err)
|
||||
return
|
||||
}
|
||||
svc, err := s.relations()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
svc, ok := s.relationsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := svc.DeleteChildren(r.Context(), cc, cr.Contract.Name, id, in)
|
||||
@@ -678,9 +909,8 @@ func (s *service) relationPivotShow(w http.ResponseWriter, r *http.Request) {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
svc, err := s.relations()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
svc, ok := s.relationsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := svc.ShowPivot(r.Context(), cc, cr.Contract.Name, id, child)
|
||||
@@ -709,9 +939,8 @@ func (s *service) relationPivotUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
writeRelationError(w, err)
|
||||
return
|
||||
}
|
||||
svc, err := s.relations()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
svc, ok := s.relationsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := svc.UpdatePivot(r.Context(), cc, cr.Contract.Name, id, child, body)
|
||||
|
||||
@@ -5,9 +5,17 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// linkedIDs lists the ids of a gadget relation's linked rows.
|
||||
@@ -231,3 +239,168 @@ func TestRelationChildSmokePivot(t *testing.T) {
|
||||
t.Fatalf("pivot row after update = %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
// sendJSON sends a JSON body with extra headers.
|
||||
func (e *conformEnv) sendJSON(t *testing.T, method, rel string, body any, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e.sendWith(t, method, rel, raw, "application/json", headers)
|
||||
}
|
||||
|
||||
// TestRelationChildSmokeDeferredCreate manages relations on a gadget that is
|
||||
// not saved yet (D-03, D-04): a part created and a member linked with a
|
||||
// pivot note under the session key are attached by the gadget's create save
|
||||
// with the same key, and no binding is left.
|
||||
func TestRelationChildSmokeDeferredCreate(t *testing.T) {
|
||||
env := newConformEnv(t)
|
||||
env.loginAs(t, env.login)
|
||||
key := newSessionKey(t)
|
||||
h := map[string]string{cabana.SessionKeyHeader: key}
|
||||
|
||||
expectStatus(t, "id 0 without a key", env.send(t, http.MethodPost, relationPath(0, "parts", "/records"), map[string]any{"label": "x"}, true), http.StatusNotFound)
|
||||
created := env.sendJSON(t, http.MethodPost, relationPath(0, "parts", "/records"), map[string]any{"label": "pending-" + env.stamp}, h)
|
||||
expectStatus(t, "deferred create", created, http.StatusCreated)
|
||||
part := dataID(t, created.Body.Bytes())
|
||||
if owner := env.partOwner(t, part); owner != nil {
|
||||
t.Fatalf("pending part already owned by %d", *owner)
|
||||
}
|
||||
if got := env.linkedIDs(t, 0, "parts", h); !slices.Contains(got, part) {
|
||||
t.Fatalf("pending parts = %v, want %d", got, part)
|
||||
}
|
||||
other := env.createGadget(t, "other-"+env.stamp, "")
|
||||
candidates := env.send(t, http.MethodGet, relationPath(other, "parts", "/candidates"), nil, true)
|
||||
expectStatus(t, "candidates of another gadget", candidates, http.StatusOK)
|
||||
if strings.Contains(candidates.Body.String(), "pending-"+env.stamp) {
|
||||
t.Fatalf("another gadget may adopt the pending part: %s", candidates.Body.String())
|
||||
}
|
||||
|
||||
link := env.sendJSON(t, http.MethodPost, relationPath(0, "members", "/link"), map[string]any{"ids": []uint{env.memberID}, "pivot": map[string]any{"note": "deferred"}}, h)
|
||||
expectStatus(t, "deferred link", link, http.StatusOK)
|
||||
pivot := env.sendWith(t, http.MethodGet, relationPath(0, "members", fmt.Sprintf("/pivot/%d", env.memberID)), nil, "", h)
|
||||
expectStatus(t, "pending pivot", pivot, http.StatusOK)
|
||||
if !strings.Contains(pivot.Body.String(), `"note":"deferred"`) {
|
||||
t.Fatalf("pending pivot = %s", pivot.Body.String())
|
||||
}
|
||||
|
||||
// Unlinking a part the session created deletes it.
|
||||
dropped := env.sendJSON(t, http.MethodPost, relationPath(0, "parts", "/records"), map[string]any{"label": "dropped-" + env.stamp}, h)
|
||||
expectStatus(t, "second deferred create", dropped, http.StatusCreated)
|
||||
droppedID := dataID(t, dropped.Body.Bytes())
|
||||
expectStatus(t, "deferred unlink", env.sendJSON(t, http.MethodPost, relationPath(0, "parts", "/unlink"), map[string]any{"ids": []uint{droppedID}}, h), http.StatusOK)
|
||||
var left int64
|
||||
if err := env.db.Model(&conformPart{}).Where("id = ?", droppedID).Count(&left).Error; err != nil || left != 0 {
|
||||
t.Fatalf("unlinked pending part rows = %d (%v)", left, err)
|
||||
}
|
||||
|
||||
gadget := env.createGadget(t, "deferred-"+env.stamp, key)
|
||||
if owner := env.partOwner(t, part); owner == nil || *owner != gadget {
|
||||
t.Fatalf("part owner after save = %v, want %d", owner, gadget)
|
||||
}
|
||||
var row conformGadgetMember
|
||||
if err := env.db.Where("gadget_id = ? AND member_id = ?", gadget, env.memberID).Take(&row).Error; err != nil {
|
||||
t.Fatalf("pivot row after save: %v", err)
|
||||
}
|
||||
if row.Note != "deferred" || row.Stamp != "linked" {
|
||||
t.Fatalf("pivot row = %+v, want note deferred and stamp linked", row)
|
||||
}
|
||||
if n := env.bindingCount(t, key); n != 0 {
|
||||
t.Fatalf("bindings left after save = %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRelationChildSmokeDeferredRollback links a member that the saved
|
||||
// gadget excludes (ExcludedRelatedIDs sees the real parent only at save):
|
||||
// the save answers 422 on the relation-manager field, nothing is created and
|
||||
// the binding stays for the next attempt.
|
||||
func TestRelationChildSmokeDeferredRollback(t *testing.T) {
|
||||
env := newConformEnv(t)
|
||||
env.loginAs(t, env.login)
|
||||
key := newSessionKey(t)
|
||||
h := map[string]string{cabana.SessionKeyHeader: key}
|
||||
member := conformMember{Email: "excluded-" + env.stamp + "@example.test"}
|
||||
if err := env.db.Create(&member).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectStatus(t, "deferred link", env.sendJSON(t, http.MethodPost, relationPath(0, "members", "/link"), map[string]any{"ids": []uint{member.ID}}, h), http.StatusOK)
|
||||
|
||||
name := fmt.Sprintf("exclude-%d-%s", member.ID, env.stamp)
|
||||
saved := env.sendJSON(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": name}, h)
|
||||
expectStatus(t, "save with an ineligible link", saved, http.StatusUnprocessableEntity)
|
||||
var body struct {
|
||||
Error struct {
|
||||
Details map[string][]string `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(saved.Body.Bytes(), &body); err != nil || len(body.Error.Details["members"]) == 0 {
|
||||
t.Fatalf("422 details = %s (%v)", saved.Body.String(), err)
|
||||
}
|
||||
var n int64
|
||||
if err := env.db.Model(&conformGadget{}).Where("name = ?", name).Count(&n).Error; err != nil || n != 0 {
|
||||
t.Fatalf("rolled back gadget rows = %d (%v)", n, err)
|
||||
}
|
||||
if got := env.bindingCount(t, key); got != 1 {
|
||||
t.Fatalf("bindings after the 422 = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRelationChildSmokeChildFile uploads an image to a part that is not
|
||||
// created yet (child 0, X-Child-Session-Key) and creates the part with the
|
||||
// same child key: the file is attached to the new part (D-17).
|
||||
func TestRelationChildSmokeChildFile(t *testing.T) {
|
||||
env := newConformEnv(t)
|
||||
env.loginAs(t, env.login)
|
||||
gadget := env.createGadget(t, "files-"+env.stamp, "")
|
||||
childKey := newSessionKey(t)
|
||||
|
||||
expectStatus(t, "child 0 list without a child key", env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/0/files/images", gadget), nil, "", nil), http.StatusNotFound)
|
||||
up := env.childUpload(t, gadget, 0, "images", "part.png", conformPNG(t), childKey)
|
||||
expectStatus(t, "child 0 upload", up, http.StatusCreated)
|
||||
file := dataID(t, up.Body.Bytes())
|
||||
|
||||
created := env.sendJSON(t, http.MethodPost, relationPath(gadget, "parts", "/records"), map[string]any{"label": "with image"}, map[string]string{cabana.ChildSessionKeyHeader: childKey})
|
||||
expectStatus(t, "create child with files", created, http.StatusCreated)
|
||||
part := dataID(t, created.Body.Bytes())
|
||||
var f attach.File
|
||||
if err := env.db.Where("id = ?", file).Take(&f).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.AttachmentType != "acme.conform.part" || f.AttachmentID != fmt.Sprint(part) || f.Field != "images" {
|
||||
t.Fatalf("file attached to %s/%s/%s, want acme.conform.part/%d/images", f.AttachmentType, f.AttachmentID, f.Field, part)
|
||||
}
|
||||
if n := env.bindingCount(t, childKey); n != 0 {
|
||||
t.Fatalf("child bindings left = %d", n)
|
||||
}
|
||||
list := env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d/files/images", gadget, part), nil, "", nil)
|
||||
if got := fileList(t, list); len(got) != 1 || got[0].Pending || got[0].URL != "" {
|
||||
t.Fatalf("child file list = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// noModelsPlugin is the conform plugin without its Models list.
|
||||
type noModelsPlugin struct{ conformPlugin }
|
||||
|
||||
func (noModelsPlugin) Models() []any { return nil }
|
||||
|
||||
// TestRelationChildSmokePurgeModels: a deferrable relation that creates
|
||||
// children needs its related model in some plugin's Models(), or
|
||||
// deferred:purge could not remove abandoned children (Pitfall 9).
|
||||
func TestRelationChildSmokePurgeModels(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-purge-models\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{noModelsPlugin{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "relation parts creates acme.conform.part records under deferral") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if _, err := cabana.Activate(backpack.New(cfg), []party.Plugin{conformPlugin{}}); err != nil {
|
||||
t.Fatalf("listed model failed boot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,13 @@ var phase09Routes = []adminRoute{
|
||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}"},
|
||||
{key: "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}"},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}"},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder"},
|
||||
{key: "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}"},
|
||||
{key: "DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", mounted: nestedGetRoute},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}"},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"},
|
||||
@@ -305,6 +312,13 @@ func phase09ProtectedCalls() []phase09Call {
|
||||
{"relation-child-delete", (*service).relationChildDelete},
|
||||
{"relation-pivot-show", (*service).relationPivotShow},
|
||||
{"relation-pivot-update", (*service).relationPivotUpdate},
|
||||
{"relation-child-file-list", (*service).relationChildFileList},
|
||||
{"relation-child-file-upload", (*service).relationChildFileUpload},
|
||||
{"relation-child-file-reorder", (*service).relationChildFileReorder},
|
||||
{"relation-child-file-update", (*service).relationChildFileUpdate},
|
||||
{"relation-child-file-remove", (*service).relationChildFileRemove},
|
||||
{"relation-child-file-download", (*service).relationChildFileDownload},
|
||||
{"relation-child-file-thumb", (*service).relationChildFileThumb},
|
||||
{"file-list", (*service).fileList},
|
||||
{"file-upload", (*service).fileUpload},
|
||||
{"file-reorder", (*service).fileReorder},
|
||||
|
||||
Reference in New Issue
Block a user