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