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:
Jakub Zych
2026-10-02 19:08:16 +02:00
parent afb05b6ee4
commit fe9e8baaf1
16 changed files with 3643 additions and 436 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2374,7 +2374,10 @@ export interface paths {
/** @description Records per page (1-100, default 20) */ /** @description Records per page (1-100, default 20) */
per_page?: number; per_page?: number;
}; };
header?: never; header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: { path: {
/** @description Vendor */ /** @description Vendor */
vendor: string; vendor: string;
@@ -2382,7 +2385,7 @@ export interface paths {
plugin: string; plugin: string;
/** @description Controller */ /** @description Controller */
controller: string; controller: string;
/** @description Owner id */ /** @description Owner id (0 for the record being created) */
id: number; id: number;
/** @description Relation name */ /** @description Relation name */
name: string; name: string;
@@ -2468,7 +2471,10 @@ export interface paths {
/** @description Records per page (1-100, default 20) */ /** @description Records per page (1-100, default 20) */
per_page?: number; per_page?: number;
}; };
header?: never; header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: { path: {
/** @description Vendor */ /** @description Vendor */
vendor: string; vendor: string;
@@ -2476,7 +2482,7 @@ export interface paths {
plugin: string; plugin: string;
/** @description Controller */ /** @description Controller */
controller: string; controller: string;
/** @description Owner id */ /** @description Owner id (0 for the record being created) */
id: number; id: number;
/** @description Relation name */ /** @description Relation name */
name: string; name: string;
@@ -2556,7 +2562,10 @@ export interface paths {
post: { post: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: { path: {
/** @description Vendor */ /** @description Vendor */
vendor: string; vendor: string;
@@ -2564,7 +2573,7 @@ export interface paths {
plugin: string; plugin: string;
/** @description Controller */ /** @description Controller */
controller: string; controller: string;
/** @description Owner id */ /** @description Owner id (0 for the record being created) */
id: number; id: number;
/** @description Relation name */ /** @description Relation name */
name: string; name: string;
@@ -2653,7 +2662,10 @@ export interface paths {
post: { post: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: { path: {
/** @description Vendor */ /** @description Vendor */
vendor: string; vendor: string;
@@ -2661,7 +2673,7 @@ export interface paths {
plugin: string; plugin: string;
/** @description Controller */ /** @description Controller */
controller: string; controller: string;
/** @description Owner id */ /** @description Owner id (0 for the record being created) */
id: number; id: number;
/** @description Relation name */ /** @description Relation name */
name: string; name: string;
@@ -2742,7 +2754,10 @@ export interface paths {
get: { get: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: { path: {
/** @description Vendor */ /** @description Vendor */
vendor: string; vendor: string;
@@ -2750,7 +2765,7 @@ export interface paths {
plugin: string; plugin: string;
/** @description Controller */ /** @description Controller */
controller: string; controller: string;
/** @description Owner id */ /** @description Owner id (0 for the record being created) */
id: number; id: number;
/** @description Relation name */ /** @description Relation name */
name: string; name: string;
@@ -2815,7 +2830,10 @@ export interface paths {
put: { put: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: { path: {
/** @description Vendor */ /** @description Vendor */
vendor: string; vendor: string;
@@ -2823,7 +2841,7 @@ export interface paths {
plugin: string; plugin: string;
/** @description Controller */ /** @description Controller */
controller: string; controller: string;
/** @description Owner id */ /** @description Owner id (0 for the record being created) */
id: number; id: number;
/** @description Relation name */ /** @description Relation name */
name: string; name: string;
@@ -2918,7 +2936,12 @@ export interface paths {
post: { post: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
/** @description Child form session key: the save attaches the child files uploaded under it */
"X-Child-Session-Key"?: string;
};
path: { path: {
/** @description Vendor */ /** @description Vendor */
vendor: string; vendor: string;
@@ -2926,7 +2949,7 @@ export interface paths {
plugin: string; plugin: string;
/** @description Controller */ /** @description Controller */
controller: string; controller: string;
/** @description Owner id */ /** @description Owner id (0 for the record being created) */
id: number; id: number;
/** @description Relation name */ /** @description Relation name */
name: string; name: string;
@@ -3016,7 +3039,10 @@ export interface paths {
get: { get: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: { path: {
/** @description Vendor */ /** @description Vendor */
vendor: string; vendor: string;
@@ -3024,7 +3050,7 @@ export interface paths {
plugin: string; plugin: string;
/** @description Controller */ /** @description Controller */
controller: string; controller: string;
/** @description Owner id */ /** @description Owner id (0 for the record being created) */
id: number; id: number;
/** @description Relation name */ /** @description Relation name */
name: string; name: string;
@@ -3089,7 +3115,12 @@ export interface paths {
put: { put: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
/** @description Child form session key: the save attaches the child files uploaded under it */
"X-Child-Session-Key"?: string;
};
path: { path: {
/** @description Vendor */ /** @description Vendor */
vendor: string; vendor: string;
@@ -3097,7 +3128,7 @@ export interface paths {
plugin: string; plugin: string;
/** @description Controller */ /** @description Controller */
controller: string; controller: string;
/** @description Owner id */ /** @description Owner id (0 for the record being created) */
id: number; id: number;
/** @description Relation name */ /** @description Relation name */
name: string; name: string;
@@ -3176,6 +3207,694 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 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).
*/
get: {
parameters: {
query?: never;
header?: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads */
"X-Child-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-array_cabana_FileItem"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
/**
* 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).
*/
post: {
parameters: {
query?: never;
header: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -) */
"X-Child-Session-Key": string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": {
/**
* Format: binary
* @description The file
*/
file_data: string;
};
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_FileItem"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 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.
*/
post: {
parameters: {
query?: never;
header?: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads */
"X-Child-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
};
cookie?: never;
};
/** @description File ids in the new order */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-array_cabana_FileItem"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* 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).
*/
put: {
parameters: {
query?: never;
header?: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads */
"X-Child-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
/** @description File id */
file: number;
};
cookie?: never;
};
/** @description Title and description */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminFileCaptionRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_FileItem"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
post?: never;
/**
* 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.
*/
delete: {
parameters: {
query?: never;
header: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -) */
"X-Child-Session-Key": string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
/** @description File id */
file: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_FileMutationResult"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 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.
*/
get: {
parameters: {
query?: never;
header?: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads */
"X-Child-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
/** @description File id */
file: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 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.
*/
get: {
parameters: {
query?: never;
header?: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads */
"X-Child-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
/** @description File id */
file: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": { "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": {
parameters: { parameters: {
query?: never; query?: never;
@@ -3189,7 +3908,10 @@ export interface paths {
post: { post: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: { path: {
/** @description Vendor */ /** @description Vendor */
vendor: string; vendor: string;
@@ -3197,7 +3919,7 @@ export interface paths {
plugin: string; plugin: string;
/** @description Controller */ /** @description Controller */
controller: string; controller: string;
/** @description Owner id */ /** @description Owner id (0 for the record being created) */
id: number; id: number;
/** @description Relation name */ /** @description Relation name */
name: string; name: string;

View File

@@ -151,6 +151,27 @@ WinterCMS names pivot form fields `pivot[role]`; both `pivot[role]` and the bare
The SPA sends pivot values when it links one record: `{"ids": [7], "pivot": {"role": "reviewer"}}`. A `pivot` object with more than one id answers 422 on `ids`, and a key that is not a pivot form field answers 422 on that key. The values are filled into the pivot model through the pivot form's fields only, validated, and then `pact.RelationBeforeLink` stamps its hook columns as before. Later, GET and PUT `.../{id}/relations/{name}/pivot/{child}` read and save the same values on an existing link. Both need a pivot form and the `link` or `update` button, and a record not linked to the parent answers 404. The SPA sends pivot values when it links one record: `{"ids": [7], "pivot": {"role": "reviewer"}}`. A `pivot` object with more than one id answers 422 on `ids`, and a key that is not a pivot form field answers 422 on that key. The values are filled into the pivot model through the pivot form's fields only, validated, and then `pact.RelationBeforeLink` stamps its hook columns as before. Later, GET and PUT `.../{id}/relations/{name}/pivot/{child}` read and save the same values on an existing link. Both need a pivot form and the `link` or `update` button, and a record not linked to the parent answers 404.
### Managers on the create screen
A relation manager also works on a record that is not saved yet, as WinterCMS's RelationController does with deferred binding. A relation is *deferrable* when it can hold its changes until the record exists: a belongsToMany always (the pivot rows are written on save), a hasMany only with a nullable `ForeignKey`. The relation schema and the `relation-manager` form field carry `deferrable`, and the SPA shows deferrable managers on the create screen.
On the create screen the record id in every relation route is `0`, and the SPA sends its form session key in `X-Session-Key`, the same key the record's file uploads use. Id 0 is accepted only for a deferrable relation, with a valid key, on a controller that declares create, when the `relation-manager` field is not hidden from the create context; otherwise it is 404. The work is held in `deferred_bindings` against the key and the signed-in administrator:
- Creating a child inserts it at once (a hasMany child with a NULL `ForeignKey`) and binds it to the session, marked as created.
- Linking binds existing records, with any pivot values, after the same eligibility checks as a link on a saved record. A record another session created is never a candidate, so it cannot be adopted before its own form is saved.
- Unlinking and deleting cancel a pending bind; a child the session created is deleted.
- The linked list shows the session's pending records, and the child and pivot routes read and edit them.
The record's first save with the same `X-Session-Key` applies all of it inside its transaction, in the order it happened: hasMany children get the new record's key, belongsToMany links get their pivot rows with the stored pivot values and `pact.RelationBeforeLink` stamps. A linked record is checked again with the saved record, because `ExcludedRelatedIDs` and `pact.RelationExtendManageQuery` saw a record without a key when it was linked; if it is no longer eligible the save answers 422 on the `relation-manager` field, nothing is saved, and the pending work stays for the next attempt. Pending work of another administrator's key is never read.
Nothing has to cancel an abandoned form. `deferred:purge` (also run daily by the scheduler) removes expired bindings and deletes the children they created. A deferrable relation that declares `create` therefore needs its related model in some plugin's `pact.HasModels` `Models()` list; otherwise the start-up stops, because the purge could not delete those children.
Existing belongsToMany managers become deferrable too. A manager without `context: update` on its `relation-manager` field now appears on the create screen; keep `context: update` to show it only after the first save.
### Files in child forms
A `fileupload` field in a relation's manage form works inside the child modal through its own routes under `.../{id}/relations/{name}/records/{child}/files/{field}`: list, upload, caption, remove, reorder, download and thumb, as for a record's own files. The child modal has its own form session key, sent in `X-Child-Session-Key`; the child's create or update with the same header attaches the files in the child's transaction. `{child}` 0 is the child being created and needs the `create` button and the child key; a saved child is scoped to the parent like the other child routes and needs `update` to change files. On a record that is not saved yet, the modal sends both headers: `X-Session-Key` for the parent and `X-Child-Session-Key` for the child.
### Messages ### Messages
A relation's `messages` block overrides the relation manager's copy, each key a phrase key; an omitted key falls back to `backend::lang.messages.relation.<key in snake_case>`. The keys are `link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked` and `empty` for the link panels, and `create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit` and `linkSubmit` for the child and pivot modals. A key that names a missing phrase stops the start-up. A relation's `messages` block overrides the relation manager's copy, each key a phrase key; an omitted key falls back to `backend::lang.messages.relation.<key in snake_case>`. The keys are `link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked` and `empty` for the link panels, and `create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit` and `linkSubmit` for the child and pivot modals. A key that names a missing phrase stops the start-up.

View File

@@ -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`). | | 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. | | 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 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`. | | 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`. | | 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). | | 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). | | 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. | | 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 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. 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.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.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options. |
| `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. | | `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.SettingsService` | Reads and transactionally updates singleton settings rows. |
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. | | `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. |
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. | | `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.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.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.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.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.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.ThumbOptions` | A fileupload field's `thumbOptions` (the preview thumbnail `mode`). |
| `cabana.FileMutationResult` | Answer of the file removal route: `removed`. | | `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.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.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`). | | `cabana.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). |
## Configuration ## Configuration

View File

@@ -539,13 +539,14 @@ func AdminDelete() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @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 name path string true "Relation name"
// @Param search query string false "Search term over the panel's searchable columns" // @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 sort query string false "Sort column (a sortable panel column)"
// @Param dir query string false "Sort direction (asc or desc)" // @Param dir query string false "Sort direction (asc or desc)"
// @Param page query integer false "Page" // @Param page query integer false "Page"
// @Param per_page query integer false "Records per page (1-100, default 20)" // @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] // @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -563,13 +564,14 @@ func AdminRelationLinked() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @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 name path string true "Relation name"
// @Param search query string false "Search term over the panel's searchable columns" // @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 sort query string false "Sort column (a sortable panel column)"
// @Param dir query string false "Sort direction (asc or desc)" // @Param dir query string false "Sort direction (asc or desc)"
// @Param page query integer false "Page" // @Param page query integer false "Page"
// @Param per_page query integer false "Records per page (1-100, default 20)" // @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] // @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -588,9 +590,10 @@ func AdminRelationCandidates() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @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 name path string true "Relation name"
// @Param body body AdminRelationLinkRequest true "Related record ids and optional pivot form values" // @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] // @Success 200 {object} Envelope[RelationMutationResult]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -609,9 +612,10 @@ func AdminRelationLink() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @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 name path string true "Relation name"
// @Param body body AdminIDsRequest true "Related record ids" // @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] // @Success 200 {object} Envelope[RelationMutationResult]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -631,9 +635,11 @@ func AdminRelationUnlink() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @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 name path string true "Relation name"
// @Param body body AdminRecord true "Field values of the manage form keyed by field 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 // @Success 201 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -653,9 +659,10 @@ func AdminRelationChildCreate() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @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 name path string true "Relation name"
// @Param child path integer true "Related record id" // @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 // @Success 200 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -675,10 +682,12 @@ func AdminRelationChildShow() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @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 name path string true "Relation name"
// @Param child path integer true "Related record id" // @Param child path integer true "Related record id"
// @Param body body AdminRecord true "Field values of the manage form keyed by field 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 200 {object} RecordEnvelope // @Success 200 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -699,9 +708,10 @@ func AdminRelationChildUpdate() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @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 name path string true "Relation name"
// @Param body body AdminIDsRequest true "Related record ids" // @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] // @Success 200 {object} Envelope[BulkResult]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -721,9 +731,10 @@ func AdminRelationChildDelete() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @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 name path string true "Relation name"
// @Param child path integer true "Related record id" // @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] // @Success 200 {object} Envelope[AdminRecord]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -743,10 +754,11 @@ func AdminRelationPivotShow() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @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 name path string true "Relation name"
// @Param child path integer true "Related record id" // @Param child path integer true "Related record id"
// @Param body body AdminRecord true "Pivot form values keyed by field name" // @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] // @Success 200 {object} Envelope[AdminRecord]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -756,6 +768,194 @@ func AdminRelationPivotShow() {}
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child} [put] // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child} [put]
func AdminRelationPivotUpdate() {} 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 // FileMutationResult is the payload of a file removal: the number of files
// removed (always 1 on success). // removed (always 1 on success).
type FileMutationResult struct { type FileMutationResult struct {

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"reflect"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -21,6 +22,12 @@ import (
// by the record's next create or update save, inside its transaction. // by the record's next create or update save, inside its transaction.
const SessionKeyHeader = "X-Session-Key" 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 // sessionKeyPattern is the accepted key shape: 32 to 128 URL-safe
// characters, at least 128 bits for a base64url key. // characters, at least 128 bits for a base64url key.
var sessionKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{32,128}$`) 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 return raw, true, nil
} }
// commitDeferred applies the file bindings of in.SessionKey to the saved // childSessionKeyFrom reads the X-Child-Session-Key header like
// target inside the save transaction (D-04), then rechecks the file limits. // 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 // It reads every binding of the key, the authenticated admin and the
// controller's morph type whose master_field is a fileupload field allowed // controller's morph type whose master_field is a fileupload field or a
// in op, locked FOR UPDATE so two saves with one key serialize, and applies // deferrable relation-manager relation allowed in op, locked FOR UPDATE so
// them in id order: a bind attaches its pending upload (on an attachOne // two saves with one key serialize, and applies them in id order. A file
// field after deleting the file it replaces), an unbind deletes the attached // bind attaches its pending upload (on an attachOne field after deleting
// file. Blobs of deleted files are removed after commit, and the applied // the file it replaces), a file unbind deletes the attached file; blobs of
// rows are deleted. Bindings of other fields stay for the purge. Then every // deleted files are removed after commit. A relation bind attaches the
// fileupload field allowed in op must hold at most maxFiles files and, when // related record (applyRelationBinding), a relation unbind detaches it. The
// required, at least one; otherwise the save fails with a 422 on the field, // applied rows are deleted; bindings of other fields stay for the purge.
// the transaction rolls back and the bindings stay in place. // 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 { 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 return nil
} }
var fields []*compiledFile var fields []*compiledFile
@@ -61,8 +86,14 @@ func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *Compil
fields = append(fields, cf) 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) ownerID := primaryText(target)
if len(fields) == 0 || ownerID == "" { if (len(fields) == 0 && len(relations) == 0) || ownerID == "" {
return nil return nil
} }
morph, err := lagoon.MorphType(tx, target) morph, err := lagoon.MorphType(tx, target)
@@ -71,21 +102,23 @@ func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *Compil
} }
principal, _ := bouncer.User(ctx) principal, _ := bouncer.User(ctx)
if in.SessionKey != "" && principal != nil && principal.Backend && principal.ID != 0 { if in.SessionKey != "" && principal != nil && principal.Backend && principal.ID != 0 {
names := make([]string, len(fields)) names := make([]string, 0, len(fields)+len(relations))
for i, cf := range fields { for _, cf := range fields {
names[i] = cf.name names = append(names, cf.name)
}
for name := range relations {
names = append(names, name)
} }
key := lagoon.DeferredKey{SessionKey: in.SessionKey, AdminID: principal.ID, MasterType: morph} key := lagoon.DeferredKey{SessionKey: in.SessionKey, AdminID: principal.ID, MasterType: morph}
rows, err := lagoon.DeferredBindings(ctx, tx, key, names) rows, err := lagoon.DeferredBindings(ctx, tx, key, names)
if err != nil { if err != nil {
return lifecycleFailure(cc, err) 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)) applied := make([]uint, 0, len(rows))
for _, row := range rows { for _, row := range rows {
cf := cc.files[row.MasterField] if cf := cc.files[row.MasterField]; cf != nil && row.SlaveType == lagoon.DeferredFileType {
if cf == nil || row.SlaveType != lagoon.DeferredFileType {
continue
}
if row.IsBind { if row.IsBind {
err = s.applyFileBind(ctx, tx, cf, morph, ownerID, row) err = s.applyFileBind(ctx, tx, cf, morph, ownerID, row)
} else { } else {
@@ -95,6 +128,19 @@ func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *Compil
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
} }
applied = append(applied, row.ID) applied = append(applied, row.ID)
continue
}
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)
}
if done {
applied = append(applied, row.ID)
}
} }
if err := lagoon.DeferredForget(ctx, tx, applied); err != nil { if err := lagoon.DeferredForget(ctx, tx, applied); err != nil {
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
@@ -125,6 +171,95 @@ func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *Compil
return nil 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 // 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 // or already attached somewhere is ignored. On an attachOne field the file
// it replaces is deleted first (WinterCMS's AttachOne::add). // it replaces is deleted first (WinterCMS's AttachOne::add).

View File

@@ -582,7 +582,10 @@ func fileItem(ctx context.Context, bucket *blob.Bucket, cf *compiledFile, f *att
// fileList serves GET .../{id}/files/{field} (dispatched by nestedGet). // fileList serves GET .../{id}/files/{field} (dispatched by nestedGet).
func (s *service) fileList(w http.ResponseWriter, r *http.Request) { func (s *service) fileList(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) { 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() db, err := s.db()
if err != nil { if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError) WriteError(w, http.StatusInternalServerError, "error", msgServerError)
@@ -592,7 +595,7 @@ func (s *service) fileList(w http.ResponseWriter, r *http.Request) {
var items []FileItem var items []FileItem
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
sc, err := parentFileScope(ctx, tx, r, cc) sc, err := fr.scope(ctx, tx, r)
if err != nil { if err != nil {
return err return err
} }
@@ -611,24 +614,22 @@ func (s *service) fileList(w http.ResponseWriter, r *http.Request) {
return return
} }
WriteData(w, http.StatusOK, items, nil) WriteData(w, http.StatusOK, items, nil)
})
} }
// fileUpload serves POST .../{id}/files/{field}: it stores one multipart // 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). // file_data part with attach.Store and binds it to the session key (D-03).
// The record's next save attaches it. // The record's next save attaches it.
func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) { func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) { s.protect(w, r, func(cc *CompiledController) { s.fileUploadOn(w, r, cc, parentFiles(cc)) })
cf := cc.files[r.PathValue("field")] }
func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
cf := fr.field(r)
if cf == nil { if cf == nil {
writeNotFound(w, r) writeNotFound(w, r)
return return
} }
if _, ok, err := sessionKeyFrom(r); err != nil || !ok { if !requireKey(w, r, fr) {
if err == nil {
err = &ValidationError{Details: map[string]any{"session_key": []string{"The session key field is required."}}}
}
writeCRUDError(w, err)
return return
} }
db, err := s.db() db, err := s.db()
@@ -662,7 +663,7 @@ func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) {
var item FileItem var item FileItem
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
sc, err := parentFileScope(ctx, tx, r, cc) sc, err := fr.scope(ctx, tx, r)
if err != nil { if err != nil {
return err return err
} }
@@ -703,7 +704,6 @@ func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
WriteData(w, http.StatusCreated, item, nil) WriteData(w, http.StatusCreated, item, nil)
})
} }
// uploadCap is the request body cap of an upload: the smaller of // 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 // withFileScope runs fn on the resolved scope of a file route inside one
// transaction and writes the error envelope on failure. // 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() db, err := s.db()
if err != nil { if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError) 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 { err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
sc, err := parentFileScope(ctx, tx, r, cc) sc, err := fr.scope(ctx, tx, r)
if err != nil { if err != nil {
return err return err
} }
return fn(ctx, tx, sc) return fn(ctx, tx, sc)
}) })
if err != nil { 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 false
} }
return true return true
} }
// requireSessionKey answers 422 on session_key when the request has no // requireKey answers 422 when the request has no valid file session key:
// valid X-Session-Key. // X-Session-Key on a record's file routes, X-Child-Session-Key on a
func requireSessionKey(w http.ResponseWriter, r *http.Request) bool { // relation child's.
_, ok, err := sessionKeyFrom(r) func requireKey(w http.ResponseWriter, r *http.Request, fr fileRoute) bool {
_, ok, err := fr.keyFrom(r)
if err == nil && !ok { 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 { if err != nil {
writeCRUDError(w, err) writeCRUDError(w, err)
@@ -951,6 +952,132 @@ func requireSessionKey(w http.ResponseWriter, r *http.Request) bool {
return true 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 // deleteBlobsAfterCommit removes a deleted file's blob and thumbnails once
// the surrounding transaction has committed. // the surrounding transaction has committed.
func deleteBlobsAfterCommit(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, f attach.File) { func deleteBlobsAfterCommit(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, f attach.File) {
@@ -970,12 +1097,15 @@ 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 // removal of an attached file to the next save, or cancels a pending upload
// at once (its row now, its blob after commit). // at once (its row now, its blob after commit).
func (s *service) fileRemove(w http.ResponseWriter, r *http.Request) { func (s *service) fileRemove(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) { s.protect(w, r, func(cc *CompiledController) { s.fileRemoveOn(w, r, cc, parentFiles(cc)) })
if cc.files[r.PathValue("field")] == nil { }
func (s *service) fileRemoveOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
if fr.field(r) == nil {
writeNotFound(w, r) writeNotFound(w, r)
return return
} }
if !requireSessionKey(w, r) { if !requireKey(w, r, fr) {
return return
} }
fileID, err := pathFileID(r) fileID, err := pathFileID(r)
@@ -984,7 +1114,7 @@ func (s *service) fileRemove(w http.ResponseWriter, r *http.Request) {
return return
} }
bucket := s.bucket() bucket := s.bucket()
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error { 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) f, err := sc.findFile(ctx, tx, fileID, true)
if err != nil { if err != nil {
return err return err
@@ -1004,7 +1134,6 @@ func (s *service) fileRemove(w http.ResponseWriter, r *http.Request) {
if ok { if ok {
WriteData(w, http.StatusOK, FileMutationResult{Removed: 1}, nil) WriteData(w, http.StatusOK, FileMutationResult{Removed: 1}, nil)
} }
})
} }
// jsonCap is the body cap of the JSON file routes: http.body_limits. // jsonCap is the body cap of the JSON file routes: http.body_limits.
@@ -1039,8 +1168,11 @@ func (s *service) decodeStrictBody(w http.ResponseWriter, r *http.Request, dest
// title and description at once (WinterCMS's onSaveAttachmentConfig). The // title and description at once (WinterCMS's onSaveAttachmentConfig). The
// field must declare useCaption. // field must declare useCaption.
func (s *service) fileUpdate(w http.ResponseWriter, r *http.Request) { func (s *service) fileUpdate(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) { s.protect(w, r, func(cc *CompiledController) { s.fileUpdateOn(w, r, cc, parentFiles(cc)) })
cf := cc.files[r.PathValue("field")] }
func (s *service) fileUpdateOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
cf := fr.field(r)
if cf == nil { if cf == nil {
writeNotFound(w, r) writeNotFound(w, r)
return return
@@ -1061,7 +1193,7 @@ func (s *service) fileUpdate(w http.ResponseWriter, r *http.Request) {
} }
bucket := s.bucket() bucket := s.bucket()
var item FileItem var item FileItem
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error { 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) f, err := sc.findFile(ctx, tx, fileID, true)
if err != nil { if err != nil {
return err return err
@@ -1086,15 +1218,17 @@ func (s *service) fileUpdate(w http.ResponseWriter, r *http.Request) {
if ok { if ok {
WriteData(w, http.StatusOK, item, nil) WriteData(w, http.StatusOK, item, nil)
} }
})
} }
// fileReorder serves POST .../{id}/files/{field}/reorder: the submitted ids // fileReorder serves POST .../{id}/files/{field}/reorder: the submitted ids
// must be exactly the field's visible files, and they receive the visible // must be exactly the field's visible files, and they receive the visible
// files' existing sort_order values, ascending, in the submitted order. // files' existing sort_order values, ascending, in the submitted order.
func (s *service) fileReorder(w http.ResponseWriter, r *http.Request) { func (s *service) fileReorder(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) { s.protect(w, r, func(cc *CompiledController) { s.fileReorderOn(w, r, cc, parentFiles(cc)) })
cf := cc.files[r.PathValue("field")] }
func (s *service) fileReorderOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
cf := fr.field(r)
if cf == nil { if cf == nil {
writeNotFound(w, r) writeNotFound(w, r)
return return
@@ -1110,7 +1244,7 @@ func (s *service) fileReorder(w http.ResponseWriter, r *http.Request) {
} }
bucket := s.bucket() bucket := s.bucket()
var items []FileItem var items []FileItem
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error { ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
files, _, err := sc.visibleFiles(tx) files, _, err := sc.visibleFiles(tx)
if err != nil { if err != nil {
return err return err
@@ -1154,7 +1288,6 @@ func (s *service) fileReorder(w http.ResponseWriter, r *http.Request) {
if ok { if ok {
WriteData(w, http.StatusOK, items, nil) WriteData(w, http.StatusOK, items, nil)
} }
})
} }
// fileDownload serves GET .../{id}/files/{field}/{file}/download. // fileDownload serves GET .../{id}/files/{field}/{file}/download.
@@ -1175,8 +1308,11 @@ func (s *service) fileThumb(w http.ResponseWriter, r *http.Request) {
// application/octet-stream attachment. Every response is nosniff, private // application/octet-stream attachment. Every response is nosniff, private
// and no-store, under a sandboxing CSP. // and no-store, under a sandboxing CSP.
func (s *service) serveProtectedFile(w http.ResponseWriter, r *http.Request, thumb bool) { func (s *service) serveProtectedFile(w http.ResponseWriter, r *http.Request, thumb bool) {
s.protect(w, r, func(cc *CompiledController) { s.protect(w, r, func(cc *CompiledController) { s.serveProtectedFileOn(w, r, cc, parentFiles(cc), thumb) })
cf := cc.files[r.PathValue("field")] }
func (s *service) serveProtectedFileOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute, thumb bool) {
cf := fr.field(r)
if cf == nil { if cf == nil {
writeNotFound(w, r) writeNotFound(w, r)
return return
@@ -1193,7 +1329,7 @@ func (s *service) serveProtectedFile(w http.ResponseWriter, r *http.Request, thu
return return
} }
var f *attach.File var f *attach.File
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error { 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) found, err := sc.findFile(ctx, tx, fileID, false)
if err != nil { if err != nil {
return err return err
@@ -1251,7 +1387,6 @@ func (s *service) serveProtectedFile(w http.ResponseWriter, r *http.Request, thu
h.Set("Content-Length", strconv.FormatInt(reader.Size(), 10)) h.Set("Content-Length", strconv.FormatInt(reader.Size(), 10))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, reader) _, _ = io.Copy(w, reader)
})
} }
// rfc5987 percent-encodes a file name for a filename* parameter: only // rfc5987 percent-encodes a file name for a filename* parameter: only

View File

@@ -108,6 +108,13 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err := checkFileLimits(reg, uploadBytes); err != nil { if err := checkFileLimits(reg, uploadBytes); err != nil {
return nil, err 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 { if err := compileContributions(reg, plugins); err != nil {
return nil, err return nil, err
} }
@@ -291,6 +298,23 @@ func (s *service) mount(r pact.Router) {
constrainChild(g) constrainChild(g)
g.Put("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", requireAjax(s.relationPivotUpdate)) g.Put("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", requireAjax(s.relationPivotUpdate))
constrainChild(g) 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 // File routes of `type: fileupload` fields (D-09). {id} 0 is the
// record being created in the X-Session-Key session. // record being created in the X-Session-Key session.
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload)) 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]+") 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) { func constrainFile(g pact.Router) {
constrainController(g) constrainController(g)
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*") 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) writeCRUDError(w, err)
return return
} }
svc, err := s.relations() svc, ok := s.relationsFor(w, r)
if err != nil { if !ok {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return return
} }
var result *RelationResult var result *RelationResult
@@ -559,9 +592,8 @@ func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link
writeCRUDError(w, err) writeCRUDError(w, err)
return return
} }
svc, err := s.relations() svc, ok := s.relationsFor(w, r)
if err != nil { if !ok {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return return
} }
var result RelationMutationResult var result RelationMutationResult
@@ -601,6 +633,25 @@ func (s *service) relations() (RelationService, error) {
return RelationService{DB: db, bucket: s.bucket(), tr: s.translator()}, nil 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) { func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) { s.protect(w, r, func(cc *CompiledController) {
if cc.Form == nil { if cc.Form == nil {

View File

@@ -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 { {"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) 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}, }, 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 { {"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) 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}, }, into[cabana.RecordEnvelope](), nil},
@@ -362,6 +392,8 @@ type conformEnv struct {
memberID uint memberID uint
gadgetID uint gadgetID uint
partID uint partID uint
partFileID uint
childKey string
} }
// sendWith sends a raw body with extra headers through the assembled router. // 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) 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. // conformPNG is a small valid PNG.
func conformPNG(t *testing.T) []byte { func conformPNG(t *testing.T) []byte {
t.Helper() t.Helper()
@@ -504,10 +565,10 @@ func newConformEnv(t *testing.T) *conformEnv {
} }
// Gadget ids restart with the recreated table: drop the files and // Gadget ids restart with the recreated table: drop the files and
// bindings an earlier run left for them. // 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) 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) t.Fatal(err)
} }
stamp := fmt.Sprintf("c%d", time.Now().UnixNano()) stamp := fmt.Sprintf("c%d", time.Now().UnixNano())
@@ -552,7 +613,7 @@ func newConformEnv(t *testing.T) *conformEnv {
if err != nil { if err != nil {
t.Fatal(err) 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 { type conformGadget struct {
@@ -622,9 +683,15 @@ type conformPart struct {
} }
func (conformPart) TableName() string { return "cabana_conform_parts" } 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) Fillable() []string { return []string{"label"} }
func (conformPart) Rules() map[string]string { return map[string]string{"label": "required"} } 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 { type conformSettings struct {
ID uint `gorm:"column:id;primaryKey"` ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"` Enabled bool `gorm:"column:enabled"`
@@ -643,6 +710,11 @@ func (conformPlugin) Boot(*backpack.App) error { return nil }
func (p conformPlugin) AdminControllers() []pact.AdminController { func (p conformPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{conformController{stamp: p.stamp}} 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 { func (conformPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.conform.access", Roles: []string{"developer"}}} 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{} }, Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} },
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"}, ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
HookPivotColumns: []string{"stamp"}, 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{} }, Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &conformPart{} },
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"}, ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
@@ -852,7 +935,6 @@ parts:
members: members:
type: relation-manager type: relation-manager
relation: members relation: members
context: [update]
parts: parts:
type: relation-manager type: relation-manager
relation: parts relation: parts
@@ -902,6 +984,11 @@ parts:
label: Label label: Label
type: text type: text
required: true required: true
images:
label: Images
type: fileupload
mode: image
useCaption: true
`), `),
"models/settings/fields.yaml": file(`fields: "models/settings/fields.yaml": file(`fields:
enabled: enabled:

View File

@@ -90,6 +90,7 @@ func TestPhase10Coverage(t *testing.T) {
want := []string{ want := []string{
"DELETE /{vendor}/{plugin}/{controller}/{id}", "DELETE /{vendor}/{plugin}/{controller}/{id}",
"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", "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/login",
"POST /auth/logout", "POST /auth/logout",
"POST /auth/refresh", "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}/delete",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", "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", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
"PUT /settings/{code}", "PUT /settings/{code}",
"PUT /{vendor}/{plugin}/{controller}/{id}", "PUT /{vendor}/{plugin}/{controller}/{id}",
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", "PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", "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}",
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
} }
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") { 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 // The routes added in Phase 10 are safe reads: GET /lang and the shared
// nested pattern serving field options, filter options and relation lists. // nested pattern serving field options, filter options and relation lists.

View File

@@ -69,9 +69,10 @@ func TestPhase10CSRF(t *testing.T) {
// refresh, logout, settings put, create, bulk-delete, widget action, // refresh, logout, settings put, create, bulk-delete, widget action,
// toolbar action, update, delete, link, unlink, file upload, file // toolbar action, update, delete, link, unlink, file upload, file
// reorder, file caption, file remove, relation child create, update // reorder, file caption, file remove, relation child create, update
// and delete, pivot update // and delete, pivot update, child file upload, reorder, caption and
if unsafe != 19 { // remove
t.Fatalf("walked %d state-changing routes, want 19: %v", unsafe, router.order) if unsafe != 23 {
t.Fatalf("walked %d state-changing routes, want 23: %v", unsafe, router.order)
} }
loginHandler := router.handlers[login] loginHandler := router.handlers[login]

View File

@@ -2,10 +2,14 @@ package cabana
import ( import (
"fmt" "fmt"
"reflect"
"sort"
"strings" "strings"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/party"
"gorm.io/gorm"
) )
type controllerRef struct { type controllerRef struct {
@@ -332,3 +336,83 @@ func (r *Registry) rolePermissions(role string) map[string]bool {
} }
return out 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
}

View File

@@ -179,6 +179,11 @@ type RelationMutationResult struct {
// RelationService executes compiled relation reads and writes. // RelationService executes compiled relation reads and writes.
type RelationService struct { type RelationService struct {
DB *gorm.DB 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; // bucket deletes the blobs of child files a save removes, after commit;
// tr localizes date bound messages. Both may be nil. // 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 var result *RelationResult
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { 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 { if err != nil {
return err return err
} }
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil { q, target, err := relationQuery(ctx, tx, cc, cr, parent, candidates)
return err
}
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, candidates)
if err != nil { if err != nil {
return err return err
} }
@@ -877,11 +879,15 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat
var result RelationMutationResult var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
return err 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 err
}) })
return result, 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 // 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 // path of the link route and of the deferred commit). Eligibility is the
// candidate query: RelationExtendManageQuery, ExcludedRelatedIDs, not linked // candidate query: RelationExtendManageQuery, ExcludedRelatedIDs, not linked
// yet, and not a child created in a pending session. An ineligible id is a // yet, and not a child created in a pending session. An ineligible id
// 422 on errField. // 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, errField string) (int, 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) ownerPK := pkUint(parent)
var pending []uint var pending []uint
var err error var err error
@@ -914,7 +920,7 @@ func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *Compi
return 0, err return 0, err
} }
if holder.Elem().Len() != len(pending) { if holder.Elem().Len() != len(pending) {
return 0, relationInvalid(errField, "contains an ineligible target") return 0, ineligible
} }
linked := 0 linked := 0
for i := 0; i < holder.Elem().Len(); i++ { 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 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. // 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) { func pendingChildIDs(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, ownerID uint, ids []uint) ([]uint, error) {
target := cr.Contract.NewRelated() target := cr.Contract.NewRelated()
@@ -1087,10 +1196,14 @@ func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, rel
var result RelationMutationResult var result RelationMutationResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
return err 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) result.Removed, err = s.unlinkRelated(ctx, tx, cc, cr, parent.model, ids)
return err return err
}) })

View File

@@ -16,28 +16,82 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
// relationParent is the parent record of a relation route, loaded through // relationParent is the parent record of a relation route: a saved record
// FormExtendQuery. // 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 { type relationParent struct {
model any model any
id uint 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 // loadParent loads the parent record of a relation route through
// loadRecord (FormExtendQuery, FOR UPDATE). A missing or hidden parent, and // loadRecord (FormExtendQuery, FOR UPDATE). Id 0 is the record being
// id 0, are recordNotFound. // created in the s.SessionKey session: it needs a deferrable relation, the
func (s RelationService) loadParent(ctx context.Context, tx *gorm.DB, cc *CompiledController, ownerID uint) (*relationParent, error) { // 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) parent, err := newWritableModel(cc)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if ownerID == 0 { morph, err := lagoon.MorphType(tx, cr.Contract.NewRelated())
return nil, recordNotFound{} if err != nil {
return nil, lifecycleFailure(cc, err)
} }
if ownerID > 0 {
if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil { if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil {
return nil, err return nil, err
} }
return &relationParent{model: parent, id: ownerID}, nil 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{}
}
principal, _ := bouncer.User(ctx)
if principal == nil || !principal.Backend || principal.ID == 0 {
return nil, recordNotFound{}
}
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 // 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 var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
return err return err
} }
@@ -106,7 +160,7 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
if err != nil { if err != nil {
return err return err
} }
if cr.hasMany() { if cr.hasMany() && !parent.unsaved() {
if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil { if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil {
return lifecycleFailure(cc, err) 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 { if err := tx.WithContext(ctx).Create(child).Error; err != nil {
return lifecycleFailure(cc, err) 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 { if err := insertPivot(ctx, tx, cc, cr, parent.model, child, nil); err != nil {
return lifecycleFailure(cc, err) 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 hook, ok := cc.Controller.(pact.RelationAfterCreate); ok && hook != nil {
if err := hook.RelationAfterCreate(ctx, cr.Contract.Name, parent.model, child); err != nil { if err := hook.RelationAfterCreate(ctx, cr.Contract.Name, parent.model, child); err != nil {
return lifecycleFailure(cc, err) 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 // 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 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'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). // the parent, and on an unsaved parent the child must be bound in the
// lock adds FOR UPDATE. // 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) { func loadChild(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint, lock bool) (any, error) {
if parent == nil || childID == 0 { if parent == nil || childID == 0 {
return nil, recordNotFound{} 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)} pk := clause.Column{Table: table, Name: primaryColumn(child)}
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(child). q := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(child).
Where(clause.Eq{Column: pk, Value: castPK(child, childID)}) 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}) 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." + 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, cr.Contract.ParentForeignKey) + " = ? AND p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) +
" = " + quotedIdent(tx, table) + "." + quotedIdent(tx, primaryColumn(child)) + ")" " = " + quotedIdent(tx, table) + "." + quotedIdent(tx, primaryColumn(child)) + ")"
@@ -201,7 +271,7 @@ func (s RelationService) ShowChild(ctx context.Context, cc *CompiledController,
var result RecordResult var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
return err return err
} }
@@ -236,7 +306,7 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
var result RecordResult var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
return err 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 { if err := tx.WithContext(ctx).Save(child).Error; err != nil {
return lifecycleFailure(cc, err) 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 hook, ok := cc.Controller.(pact.RelationAfterUpdate); ok && hook != nil {
if err := hook.RelationAfterUpdate(ctx, cr.Contract.Name, parent.model, child); err != nil { if err := hook.RelationAfterUpdate(ctx, cr.Contract.Name, parent.model, child); err != nil {
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
@@ -290,7 +363,7 @@ func (s RelationService) DeleteChildren(ctx context.Context, cc *CompiledControl
var result BulkResult var result BulkResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
return err return err
} }
@@ -303,6 +376,11 @@ func (s RelationService) DeleteChildren(ctx context.Context, cc *CompiledControl
children = append(children, child) children = append(children, child)
} }
for _, child := range children { 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 { if err := s.deleteChild(ctx, tx, cc, cr, parent, child); err != nil {
return err return err
} }
@@ -377,10 +455,24 @@ func (s RelationService) ShowPivot(ctx context.Context, cc *CompiledController,
var data map[string]any var data map[string]any
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
return err 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) row, err := loadPivotRow(ctx, tx, cr, parent, childID)
if err != nil { if err != nil {
return err return err
@@ -408,10 +500,14 @@ func (s RelationService) UpdatePivot(ctx context.Context, cc *CompiledController
var data map[string]any var data map[string]any
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withTx(ctx, tx)
parent, err := s.loadParent(ctx, tx, cc, ownerID) parent, err := s.loadParent(ctx, tx, cc, cr, ownerID)
if err != nil { if err != nil {
return err 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) row, err := loadPivotRow(ctx, tx, cr, parent, childID)
if err != nil { if err != nil {
return err return err
@@ -428,6 +524,77 @@ func (s RelationService) UpdatePivot(ctx context.Context, cc *CompiledController
return data, err 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 // pivotRecord projects a pivot row through the pivot form, keyed by the
// related record's id. // related record's id.
func pivotRecord(cr *CompiledRelation, row any, childID uint) map[string]any { 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 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 // relationButton resolves the route's relation and refuses (403) a route
// whose toolbar button the view panel does not declare. An unknown relation // whose toolbar button the view panel does not declare. An unknown relation
// is 404. It writes the response and returns nil on refusal. // 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) writeRelationError(w, err)
return return
} }
svc, err := s.relations() svc, ok := s.relationsFor(w, r)
if err != nil { if !ok {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return 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 { if err != nil {
writeRelationError(w, err) writeRelationError(w, err)
return return
@@ -563,9 +792,8 @@ func (s *service) relationChildShow(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err) writeCRUDError(w, err)
return return
} }
svc, err := s.relations() svc, ok := s.relationsFor(w, r)
if err != nil { if !ok {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return return
} }
rec, err := svc.ShowChild(r.Context(), cc, cr.Contract.Name, id, child) 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) writeRelationError(w, err)
return return
} }
svc, err := s.relations() svc, ok := s.relationsFor(w, r)
if err != nil { if !ok {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return 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 { if err != nil {
writeRelationError(w, err) writeRelationError(w, err)
return return
@@ -626,9 +858,8 @@ func (s *service) relationChildDelete(w http.ResponseWriter, r *http.Request) {
writeRelationError(w, err) writeRelationError(w, err)
return return
} }
svc, err := s.relations() svc, ok := s.relationsFor(w, r)
if err != nil { if !ok {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return return
} }
result, err := svc.DeleteChildren(r.Context(), cc, cr.Contract.Name, id, in) 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) writeCRUDError(w, err)
return return
} }
svc, err := s.relations() svc, ok := s.relationsFor(w, r)
if err != nil { if !ok {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return return
} }
data, err := svc.ShowPivot(r.Context(), cc, cr.Contract.Name, id, child) 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) writeRelationError(w, err)
return return
} }
svc, err := s.relations() svc, ok := s.relationsFor(w, r)
if err != nil { if !ok {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return return
} }
data, err := svc.UpdatePivot(r.Context(), cc, cr.Contract.Name, id, child, body) data, err := svc.UpdatePivot(r.Context(), cc, cr.Contract.Name, id, child, body)

View File

@@ -5,9 +5,17 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"slices" "slices"
"strings" "strings"
"testing" "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. // 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) 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)
}
}

View File

@@ -68,6 +68,13 @@ var phase09Routes = []adminRoute{
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}"}, {key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}"},
{key: "PUT /{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: "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}"},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"},
@@ -305,6 +312,13 @@ func phase09ProtectedCalls() []phase09Call {
{"relation-child-delete", (*service).relationChildDelete}, {"relation-child-delete", (*service).relationChildDelete},
{"relation-pivot-show", (*service).relationPivotShow}, {"relation-pivot-show", (*service).relationPivotShow},
{"relation-pivot-update", (*service).relationPivotUpdate}, {"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-list", (*service).fileList},
{"file-upload", (*service).fileUpload}, {"file-upload", (*service).fileUpload},
{"file-reorder", (*service).fileReorder}, {"file-reorder", (*service).fileReorder},