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:
File diff suppressed because it is too large
Load Diff
762
admin/src/api/schema.d.ts
vendored
762
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,30 +102,45 @@ 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 {
|
if row.IsBind {
|
||||||
|
err = s.applyFileBind(ctx, tx, cf, morph, ownerID, row)
|
||||||
|
} else {
|
||||||
|
err = s.applyFileUnbind(ctx, tx, cf, morph, ownerID, row)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
applied = append(applied, row.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if row.IsBind {
|
cr := relations[row.MasterField]
|
||||||
err = s.applyFileBind(ctx, tx, cf, morph, ownerID, row)
|
if cr == nil {
|
||||||
} else {
|
continue
|
||||||
err = s.applyFileUnbind(ctx, tx, cf, morph, ownerID, row)
|
|
||||||
}
|
}
|
||||||
|
done, err := rel.applyRelationBinding(ctx, tx, cc, cr, parent, row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return lifecycleFailure(cc, err)
|
return lifecycleFailure(cc, err)
|
||||||
}
|
}
|
||||||
applied = append(applied, row.ID)
|
if done {
|
||||||
|
applied = append(applied, row.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := lagoon.DeferredForget(ctx, tx, applied); err != nil {
|
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).
|
||||||
|
|||||||
@@ -582,128 +582,128 @@ func fileItem(ctx context.Context, bucket *blob.Bucket, cf *compiledFile, f *att
|
|||||||
|
|
||||||
// fileList serves GET .../{id}/files/{field} (dispatched by nestedGet).
|
// 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)) })
|
||||||
db, err := s.db()
|
}
|
||||||
|
|
||||||
|
func (s *service) fileListOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
|
||||||
|
db, err := s.db()
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucket := s.bucket()
|
||||||
|
var items []FileItem
|
||||||
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
ctx = withTx(ctx, tx)
|
||||||
|
sc, err := fr.scope(ctx, tx, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
bucket := s.bucket()
|
files, pending, err := sc.visibleFiles(tx)
|
||||||
var items []FileItem
|
|
||||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
|
||||||
ctx = withTx(ctx, tx)
|
|
||||||
sc, err := parentFileScope(ctx, tx, r, cc)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
files, pending, err := sc.visibleFiles(tx)
|
|
||||||
if err != nil {
|
|
||||||
return lifecycleFailure(cc, err)
|
|
||||||
}
|
|
||||||
items = make([]FileItem, 0, len(files))
|
|
||||||
for i := range files {
|
|
||||||
items = append(items, fileItem(ctx, bucket, sc.file, &files[i], pending[files[i].ID]))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeCRUDError(w, err)
|
return lifecycleFailure(cc, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
WriteData(w, http.StatusOK, items, nil)
|
items = make([]FileItem, 0, len(files))
|
||||||
|
for i := range files {
|
||||||
|
items = append(items, fileItem(ctx, bucket, sc.file, &files[i], pending[files[i].ID]))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, items, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileUpload serves POST .../{id}/files/{field}: it stores one multipart
|
// 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")]
|
}
|
||||||
if cf == nil {
|
|
||||||
writeNotFound(w, r)
|
func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
|
||||||
return
|
cf := fr.field(r)
|
||||||
}
|
if cf == nil {
|
||||||
if _, ok, err := sessionKeyFrom(r); err != nil || !ok {
|
writeNotFound(w, r)
|
||||||
if err == nil {
|
return
|
||||||
err = &ValidationError{Details: map[string]any{"session_key": []string{"The session key field is required."}}}
|
}
|
||||||
}
|
if !requireKey(w, r, fr) {
|
||||||
writeCRUDError(w, err)
|
return
|
||||||
return
|
}
|
||||||
}
|
db, err := s.db()
|
||||||
db, err := s.db()
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucket := s.bucket()
|
||||||
|
if bucket == nil {
|
||||||
|
slog.Default().ErrorContext(r.Context(), "cabana: file upload without a storage bucket", "controller", controllerID(cc))
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))}
|
||||||
|
r.Body = io.NopCloser(body)
|
||||||
|
mr, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, invalidBody())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
s.writeFileError(w, r, cf, body, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if part.FormName() != "file_data" || strings.TrimSpace(part.FileName()) == "" {
|
||||||
|
writeCRUDError(w, invalidBody())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var stored *attach.File
|
||||||
|
var item FileItem
|
||||||
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
ctx = withTx(ctx, tx)
|
||||||
|
sc, err := fr.scope(ctx, tx, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
bucket := s.bucket()
|
if cf.relation.Many && cf.maxFiles > 0 {
|
||||||
if bucket == nil {
|
files, _, err := sc.visibleFiles(tx)
|
||||||
slog.Default().ErrorContext(r.Context(), "cabana: file upload without a storage bucket", "controller", controllerID(cc))
|
|
||||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))}
|
|
||||||
r.Body = io.NopCloser(body)
|
|
||||||
mr, err := r.MultipartReader()
|
|
||||||
if err != nil {
|
|
||||||
writeCRUDError(w, invalidBody())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
part, err := mr.NextPart()
|
|
||||||
if err != nil {
|
|
||||||
s.writeFileError(w, r, cf, body, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if part.FormName() != "file_data" || strings.TrimSpace(part.FileName()) == "" {
|
|
||||||
writeCRUDError(w, invalidBody())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var stored *attach.File
|
|
||||||
var item FileItem
|
|
||||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
|
||||||
ctx = withTx(ctx, tx)
|
|
||||||
sc, err := parentFileScope(ctx, tx, r, cc)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if cf.relation.Many && cf.maxFiles > 0 {
|
|
||||||
files, _, err := sc.visibleFiles(tx)
|
|
||||||
if err != nil {
|
|
||||||
return lifecycleFailure(cc, err)
|
|
||||||
}
|
|
||||||
if len(files) >= cf.maxFiles {
|
|
||||||
return &ValidationError{Details: fieldDetail(cf.name, fieldMessage(ctx, s.translator(), "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)}))}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: cf.relation.Public}, cf.limits)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
stored = f
|
|
||||||
// Exactly one part: anything after file_data is refused.
|
|
||||||
if _, err := mr.NextPart(); !errors.Is(err, io.EOF) {
|
|
||||||
if err == nil {
|
|
||||||
return invalidBody()
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), nil); err != nil {
|
|
||||||
return lifecycleFailure(cc, err)
|
return lifecycleFailure(cc, err)
|
||||||
}
|
}
|
||||||
item = fileItem(ctx, bucket, cf, f, true)
|
if len(files) >= cf.maxFiles {
|
||||||
return nil
|
return &ValidationError{Details: fieldDetail(cf.name, fieldMessage(ctx, s.translator(), "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)}))}
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
// attach.Store wrote the blob before the row; a rolled-back
|
|
||||||
// transaction leaves it to this caller (12.2-01).
|
|
||||||
if stored != nil {
|
|
||||||
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
|
|
||||||
}
|
}
|
||||||
s.writeFileError(w, r, cf, body, err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
WriteData(w, http.StatusCreated, item, nil)
|
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: cf.relation.Public}, cf.limits)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stored = f
|
||||||
|
// Exactly one part: anything after file_data is refused.
|
||||||
|
if _, err := mr.NextPart(); !errors.Is(err, io.EOF) {
|
||||||
|
if err == nil {
|
||||||
|
return invalidBody()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), nil); err != nil {
|
||||||
|
return lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
item = fileItem(ctx, bucket, cf, f, true)
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
// attach.Store wrote the blob before the row; a rolled-back
|
||||||
|
// transaction leaves it to this caller (12.2-01).
|
||||||
|
if stored != nil {
|
||||||
|
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
|
||||||
|
}
|
||||||
|
s.writeFileError(w, r, cf, body, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusCreated, item, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// uploadCap is the request body cap of an upload: the smaller of
|
// 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,41 +1097,43 @@ func deleteBlobsAfterCommit(ctx context.Context, tx *gorm.DB, bucket *blob.Bucke
|
|||||||
// removal of an attached file to the next save, or cancels a pending upload
|
// 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 {
|
}
|
||||||
writeNotFound(w, r)
|
|
||||||
return
|
func (s *service) fileRemoveOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
|
||||||
}
|
if fr.field(r) == nil {
|
||||||
if !requireSessionKey(w, r) {
|
writeNotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fileID, err := pathFileID(r)
|
if !requireKey(w, r, fr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileID, err := pathFileID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucket := s.bucket()
|
||||||
|
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||||
|
f, err := sc.findFile(ctx, tx, fileID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeCRUDError(w, err)
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
bucket := s.bucket()
|
cancelled, err := lagoon.DeferredUnbind(ctx, tx, sc.key, sc.file.name, lagoon.DeferredFileType, uitoa(f.ID))
|
||||||
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
if err != nil {
|
||||||
f, err := sc.findFile(ctx, tx, fileID, true)
|
return err
|
||||||
if err != nil {
|
}
|
||||||
|
if cancelled != nil && f.AttachmentID == "" {
|
||||||
|
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id = ?", f.ID).Delete(&attach.File{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cancelled, err := lagoon.DeferredUnbind(ctx, tx, sc.key, sc.file.name, lagoon.DeferredFileType, uitoa(f.ID))
|
deleteBlobsAfterCommit(ctx, tx, bucket, *f)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if cancelled != nil && f.AttachmentID == "" {
|
|
||||||
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id = ?", f.ID).Delete(&attach.File{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
deleteBlobsAfterCommit(ctx, tx, bucket, *f)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if ok {
|
|
||||||
WriteData(w, http.StatusOK, FileMutationResult{Removed: 1}, nil)
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
if ok {
|
||||||
|
WriteData(w, http.StatusOK, FileMutationResult{Removed: 1}, nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// jsonCap is the body cap of the JSON file routes: http.body_limits.
|
// jsonCap is the body cap of the JSON file routes: http.body_limits.
|
||||||
@@ -1039,122 +1168,126 @@ func (s *service) decodeStrictBody(w http.ResponseWriter, r *http.Request, dest
|
|||||||
// title and description at once (WinterCMS's onSaveAttachmentConfig). The
|
// 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")]
|
}
|
||||||
if cf == nil {
|
|
||||||
writeNotFound(w, r)
|
func (s *service) fileUpdateOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
|
||||||
return
|
cf := fr.field(r)
|
||||||
}
|
if cf == nil {
|
||||||
if !cf.field.UseCaption {
|
writeNotFound(w, r)
|
||||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
return
|
||||||
return
|
}
|
||||||
}
|
if !cf.field.UseCaption {
|
||||||
fileID, err := pathFileID(r)
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileID, err := pathFileID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in AdminFileCaptionRequest
|
||||||
|
if err := s.decodeStrictBody(w, r, &in); err != nil {
|
||||||
|
s.writeFileError(w, r, cf, nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucket := s.bucket()
|
||||||
|
var item FileItem
|
||||||
|
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||||
|
f, err := sc.findFile(ctx, tx, fileID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeCRUDError(w, err)
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
var in AdminFileCaptionRequest
|
updates := map[string]any{}
|
||||||
if err := s.decodeStrictBody(w, r, &in); err != nil {
|
if in.Title != nil {
|
||||||
s.writeFileError(w, r, cf, nil, err)
|
updates["title"] = *in.Title
|
||||||
return
|
f.Title = in.Title
|
||||||
}
|
}
|
||||||
bucket := s.bucket()
|
if in.Description != nil {
|
||||||
var item FileItem
|
updates["description"] = *in.Description
|
||||||
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
f.Description = in.Description
|
||||||
f, err := sc.findFile(ctx, tx, fileID, true)
|
}
|
||||||
if err != nil {
|
if len(updates) > 0 {
|
||||||
|
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).Where("id = ?", f.ID).Updates(updates).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
updates := map[string]any{}
|
|
||||||
if in.Title != nil {
|
|
||||||
updates["title"] = *in.Title
|
|
||||||
f.Title = in.Title
|
|
||||||
}
|
|
||||||
if in.Description != nil {
|
|
||||||
updates["description"] = *in.Description
|
|
||||||
f.Description = in.Description
|
|
||||||
}
|
|
||||||
if len(updates) > 0 {
|
|
||||||
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).Where("id = ?", f.ID).Updates(updates).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item = fileItem(ctx, bucket, cf, f, f.AttachmentID == "")
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if ok {
|
|
||||||
WriteData(w, http.StatusOK, item, nil)
|
|
||||||
}
|
}
|
||||||
|
item = fileItem(ctx, bucket, cf, f, f.AttachmentID == "")
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
if ok {
|
||||||
|
WriteData(w, http.StatusOK, item, nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileReorder serves POST .../{id}/files/{field}/reorder: the submitted ids
|
// 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")]
|
}
|
||||||
if cf == nil {
|
|
||||||
writeNotFound(w, r)
|
func (s *service) fileReorderOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) {
|
||||||
return
|
cf := fr.field(r)
|
||||||
|
if cf == nil {
|
||||||
|
writeNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !cf.relation.Many {
|
||||||
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in AdminIDsRequest
|
||||||
|
if err := s.decodeStrictBody(w, r, &in); err != nil {
|
||||||
|
s.writeFileError(w, r, cf, nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucket := s.bucket()
|
||||||
|
var items []FileItem
|
||||||
|
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||||
|
files, _, err := sc.visibleFiles(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if !cf.relation.Many {
|
byID := make(map[uint]int, len(files))
|
||||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
orders := make([]int, len(files))
|
||||||
return
|
for i, f := range files {
|
||||||
|
byID[f.ID] = i
|
||||||
|
orders[i] = f.SortOrder
|
||||||
}
|
}
|
||||||
var in AdminIDsRequest
|
seen := map[uint]bool{}
|
||||||
if err := s.decodeStrictBody(w, r, &in); err != nil {
|
valid := len(in.IDs) == len(files)
|
||||||
s.writeFileError(w, r, cf, nil, err)
|
for _, raw := range in.IDs {
|
||||||
return
|
id := uint(raw)
|
||||||
|
if _, known := byID[id]; !known || seen[id] || uint64(id) != raw {
|
||||||
|
valid = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
}
|
}
|
||||||
bucket := s.bucket()
|
if !valid {
|
||||||
var items []FileItem
|
return &ValidationError{Details: map[string]any{"ids": []string{"The ids field must list every file of the field exactly once."}}}
|
||||||
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
}
|
||||||
files, _, err := sc.visibleFiles(tx)
|
slices.Sort(orders)
|
||||||
if err != nil {
|
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
|
||||||
|
for i, raw := range in.IDs {
|
||||||
|
if err := q.Model(&attach.File{}).Where("id = ?", uint(raw)).Update("sort_order", orders[i]).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
byID := make(map[uint]int, len(files))
|
|
||||||
orders := make([]int, len(files))
|
|
||||||
for i, f := range files {
|
|
||||||
byID[f.ID] = i
|
|
||||||
orders[i] = f.SortOrder
|
|
||||||
}
|
|
||||||
seen := map[uint]bool{}
|
|
||||||
valid := len(in.IDs) == len(files)
|
|
||||||
for _, raw := range in.IDs {
|
|
||||||
id := uint(raw)
|
|
||||||
if _, known := byID[id]; !known || seen[id] || uint64(id) != raw {
|
|
||||||
valid = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
seen[id] = true
|
|
||||||
}
|
|
||||||
if !valid {
|
|
||||||
return &ValidationError{Details: map[string]any{"ids": []string{"The ids field must list every file of the field exactly once."}}}
|
|
||||||
}
|
|
||||||
slices.Sort(orders)
|
|
||||||
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
|
|
||||||
for i, raw := range in.IDs {
|
|
||||||
if err := q.Model(&attach.File{}).Where("id = ?", uint(raw)).Update("sort_order", orders[i]).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
files, pending, err := sc.visibleFiles(tx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items = make([]FileItem, 0, len(files))
|
|
||||||
for i := range files {
|
|
||||||
items = append(items, fileItem(ctx, bucket, cf, &files[i], pending[files[i].ID]))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if ok {
|
|
||||||
WriteData(w, http.StatusOK, items, nil)
|
|
||||||
}
|
}
|
||||||
|
files, pending, err := sc.visibleFiles(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items = make([]FileItem, 0, len(files))
|
||||||
|
for i := range files {
|
||||||
|
items = append(items, fileItem(ctx, bucket, cf, &files[i], pending[files[i].ID]))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
if ok {
|
||||||
|
WriteData(w, http.StatusOK, items, nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileDownload serves GET .../{id}/files/{field}/{file}/download.
|
// fileDownload serves GET .../{id}/files/{field}/{file}/download.
|
||||||
@@ -1175,83 +1308,85 @@ func (s *service) fileThumb(w http.ResponseWriter, r *http.Request) {
|
|||||||
// application/octet-stream attachment. Every response is nosniff, private
|
// 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")]
|
}
|
||||||
if cf == nil {
|
|
||||||
|
func (s *service) serveProtectedFileOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute, thumb bool) {
|
||||||
|
cf := fr.field(r)
|
||||||
|
if cf == nil {
|
||||||
|
writeNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileID, err := pathFileID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucket := s.bucket()
|
||||||
|
if bucket == nil {
|
||||||
|
slog.Default().ErrorContext(r.Context(), "cabana: protected file route without a storage bucket", "controller", controllerID(cc))
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var f *attach.File
|
||||||
|
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
||||||
|
found, err := sc.findFile(ctx, tx, fileID, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if found.Public() {
|
||||||
|
return recordNotFound{}
|
||||||
|
}
|
||||||
|
f = found
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
key := attach.BlobKey(f.DiskName)
|
||||||
|
contentType := f.ContentType
|
||||||
|
if thumb {
|
||||||
|
if !slices.Contains(attach.AllowedImageMIMEs, f.ContentType) {
|
||||||
writeNotFound(w, r)
|
writeNotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fileID, err := pathFileID(r)
|
key, err = f.ThumbKey(ctx, bucket, cf.thumbW, cf.thumbH, cf.thumbMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeCRUDError(w, err)
|
slog.Default().ErrorContext(ctx, "cabana: protected thumbnail failed", "file_id", f.ID, "error", err)
|
||||||
return
|
|
||||||
}
|
|
||||||
bucket := s.bucket()
|
|
||||||
if bucket == nil {
|
|
||||||
slog.Default().ErrorContext(r.Context(), "cabana: protected file route without a storage bucket", "controller", controllerID(cc))
|
|
||||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var f *attach.File
|
contentType = ""
|
||||||
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
|
}
|
||||||
found, err := sc.findFile(ctx, tx, fileID, false)
|
reader, err := bucket.NewReader(ctx, key, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
if gcerrors.Code(err) == gcerrors.NotFound {
|
||||||
}
|
writeNotFound(w, r)
|
||||||
if found.Public() {
|
|
||||||
return recordNotFound{}
|
|
||||||
}
|
|
||||||
f = found
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if !ok {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := r.Context()
|
slog.Default().ErrorContext(ctx, "cabana: protected file read failed", "file_id", f.ID, "error", err)
|
||||||
key := attach.BlobKey(f.DiskName)
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
contentType := f.ContentType
|
return
|
||||||
if thumb {
|
}
|
||||||
if !slices.Contains(attach.AllowedImageMIMEs, f.ContentType) {
|
defer reader.Close()
|
||||||
writeNotFound(w, r)
|
if contentType == "" {
|
||||||
return
|
contentType = reader.ContentType()
|
||||||
}
|
}
|
||||||
key, err = f.ThumbKey(ctx, bucket, cf.thumbW, cf.thumbH, cf.thumbMode)
|
contentType = strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
|
||||||
if err != nil {
|
h := w.Header()
|
||||||
slog.Default().ErrorContext(ctx, "cabana: protected thumbnail failed", "file_id", f.ID, "error", err)
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
h.Set("Cache-Control", "private, no-store")
|
||||||
return
|
h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||||
}
|
if slices.Contains(attach.AllowedImageMIMEs, contentType) {
|
||||||
contentType = ""
|
h.Set("Content-Type", contentType)
|
||||||
}
|
} else {
|
||||||
reader, err := bucket.NewReader(ctx, key, nil)
|
h.Set("Content-Type", "application/octet-stream")
|
||||||
if err != nil {
|
h.Set("Content-Disposition", "attachment; filename*=UTF-8''"+rfc5987(f.FileName))
|
||||||
if gcerrors.Code(err) == gcerrors.NotFound {
|
}
|
||||||
writeNotFound(w, r)
|
h.Set("Content-Length", strconv.FormatInt(reader.Size(), 10))
|
||||||
return
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
_, _ = io.Copy(w, reader)
|
||||||
slog.Default().ErrorContext(ctx, "cabana: protected file read failed", "file_id", f.ID, "error", err)
|
|
||||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer reader.Close()
|
|
||||||
if contentType == "" {
|
|
||||||
contentType = reader.ContentType()
|
|
||||||
}
|
|
||||||
contentType = strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
|
|
||||||
h := w.Header()
|
|
||||||
h.Set("X-Content-Type-Options", "nosniff")
|
|
||||||
h.Set("Cache-Control", "private, no-store")
|
|
||||||
h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
|
|
||||||
if slices.Contains(attach.AllowedImageMIMEs, contentType) {
|
|
||||||
h.Set("Content-Type", contentType)
|
|
||||||
} else {
|
|
||||||
h.Set("Content-Type", "application/octet-stream")
|
|
||||||
h.Set("Content-Disposition", "attachment; filename*=UTF-8''"+rfc5987(f.FileName))
|
|
||||||
}
|
|
||||||
h.Set("Content-Length", strconv.FormatInt(reader.Size(), 10))
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = io.Copy(w, reader)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// rfc5987 percent-encodes a file name for a filename* parameter: only
|
// rfc5987 percent-encodes a file name for a filename* parameter: only
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
|||||||
if err := checkFileLimits(reg, uploadBytes); err != nil {
|
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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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())
|
||||||
|
if err != nil {
|
||||||
|
return nil, lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
if ownerID > 0 {
|
||||||
|
if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &relationParent{model: parent, id: ownerID, morph: morph}, nil
|
||||||
|
}
|
||||||
|
if s.SessionKey == "" || !cr.deferrable || !cc.operationDeclared("create") || !contextAllows(cc, cr.fieldName, "create") {
|
||||||
return nil, recordNotFound{}
|
return nil, recordNotFound{}
|
||||||
}
|
}
|
||||||
if err := loadRecord(ctx, tx, cc, parent, castPK(parent, ownerID)); err != nil {
|
principal, _ := bouncer.User(ctx)
|
||||||
return nil, err
|
if principal == nil || !principal.Backend || principal.ID == 0 {
|
||||||
|
return nil, recordNotFound{}
|
||||||
}
|
}
|
||||||
return &relationParent{model: parent, id: ownerID}, nil
|
master, err := lagoon.MorphType(tx, parent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
key := lagoon.DeferredKey{SessionKey: s.SessionKey, AdminID: principal.ID, MasterType: master}
|
||||||
|
return &relationParent{model: parent, key: &key, morph: morph}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// boundSlaves is the subquery of the unsaved parent's pending binds for the
|
||||||
|
// relation (WinterCMS withDeferred): CAST(pk AS TEXT) IN (?).
|
||||||
|
func (p *relationParent) boundSlaves(tx *gorm.DB, cr *CompiledRelation) *gorm.DB {
|
||||||
|
return lagoon.DeferredSlaves(tx, *p.key, cr.Contract.Name, p.morph, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// relationQuery is relationBaseQuery for a resolved parent. On an unsaved
|
||||||
|
// parent the linked rows are the session's pending binds, and candidates
|
||||||
|
// leave those out.
|
||||||
|
func relationQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, candidates bool) (*gorm.DB, any, error) {
|
||||||
|
if !parent.unsaved() {
|
||||||
|
return relationBaseQuery(ctx, tx, cc, cr, parent.model, candidates)
|
||||||
|
}
|
||||||
|
target := cr.Contract.NewRelated()
|
||||||
|
column := quotedIdent(tx, tableName(target)) + "." + quotedIdent(tx, primaryColumn(target))
|
||||||
|
if !candidates {
|
||||||
|
return tx.WithContext(ctx).Model(target).Where("CAST("+column+" AS TEXT) IN (?)", parent.boundSlaves(tx, cr)), target, nil
|
||||||
|
}
|
||||||
|
q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent.model, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return q.Where("CAST("+column+" AS TEXT) NOT IN (?)", parent.boundSlaves(tx, cr)), target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fillChild fills and validates a child of a relation form like the
|
// 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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
Reference in New Issue
Block a user