From e54fd257ee29961002224568527738b642825cf2 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 18:11:56 +0200 Subject: [PATCH] feat(12.2-02): add file removal, caption, reorder and protected downloads - DELETE, PUT and POST reorder under .../{id}/files/{field}, each scoped by one parent query (404 for a foreign file) - protected download and thumb routes: is_public=false only, nosniff, private no-store, sandbox CSP, inline only for jpeg/png/gif/webp - the save applies deferred removals, replaces attachOne files and rechecks maxFiles and required - blobs of deleted files are removed after commit - swagger2openapi emits binary content for file responses - admin OpenAPI, TS types, conformance, README and attachments docs --- admin/openapi/admin.json | 721 ++++++++++++++++++++ admin/src/api/schema.d.ts | 476 +++++++++++++ docs/backend/forms.md | 2 + docs/database/attachments.md | 9 + internal/tools/swagger2openapi/main.go | 27 +- internal/tools/swagger2openapi/main_test.go | 15 + modules/cabana/README.md | 12 +- modules/cabana/admin_openapi.go | 115 ++++ modules/cabana/crud.go | 7 + modules/cabana/deferred.go | 148 +++- modules/cabana/field_file.go | 428 +++++++++++- modules/cabana/fileupload_smoke_test.go | 190 ++++++ modules/cabana/http.go | 17 +- modules/cabana/openapi_conformance_test.go | 130 +++- modules/cabana/phase10_coverage_test.go | 5 +- modules/cabana/phase10_csrf_test.go | 7 +- modules/cabana/security_coverage_test.go | 10 + 17 files changed, 2237 insertions(+), 82 deletions(-) diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 8cd898c..9ea03f4 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -29,6 +29,17 @@ ], "type": "object" }, + "cabana.AdminFileCaptionRequest": { + "properties": { + "description": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, "cabana.AdminIDsRequest": { "properties": { "ids": { @@ -353,6 +364,21 @@ ], "type": "object" }, + "cabana.Envelope-cabana_FileMutationResult": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.FileMutationResult" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-cabana_FormView": { "properties": { "data": { @@ -538,6 +564,17 @@ ], "type": "object" }, + "cabana.FileMutationResult": { + "properties": { + "removed": { + "type": "integer" + } + }, + "required": [ + "removed" + ], + "type": "object" + }, "cabana.FilterOption": { "properties": { "label": { @@ -3923,6 +3960,690 @@ ] } }, + "/{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder": { + "post": { + "description": "ids must be exactly the field's visible files (attached minus pending removals plus pending uploads); they receive the existing sort_order values in the submitted order, at once. 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": "fileupload field name", + "in": "path", + "name": "field", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Form session key; needed for pending uploads", + "in": "header", + "name": "X-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 the files of a field", + "tags": [ + "admin" + ] + } + }, + "/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}": { + "delete": { + "description": "Removing an attached file is deferred to the record's next save with the same X-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": "fileupload field name", + "in": "path", + "name": "field", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "File id", + "in": "path", + "name": "file", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "Form session key", + "in": "header", + "name": "X-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 file", + "tags": [ + "admin" + ] + }, + "put": { + "description": "Saves at once (not deferred). The field must declare useCaption, otherwise 403. Omitted keys are left unchanged; unknown keys are refused.", + "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": "fileupload field name", + "in": "path", + "name": "field", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "File id", + "in": "path", + "name": "file", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "Form session key; needed for a pending upload", + "in": "header", + "name": "X-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 file's title and description", + "tags": [ + "admin" + ] + } + }, + "/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download": { + "get": { + "description": "Streams a file of a protected (Public false) relation that belongs to a record the admin may load, or is pending in the admin's own session. Public files are 404. JPEG, PNG, GIF and WebP are served inline with their type; everything else as an application/octet-stream attachment. Responses carry X-Content-Type-Options nosniff, Cache-Control private, no-store and a sandboxing Content-Security-Policy.", + "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": "fileupload field name", + "in": "path", + "name": "field", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "File id", + "in": "path", + "name": "file", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "Form session key; needed for a pending upload", + "in": "header", + "name": "X-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 protected file", + "tags": [ + "admin" + ] + } + }, + "/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb": { + "get": { + "description": "The preview thumbnail (imageWidth by imageHeight, 240 by 240 by default, in thumbOptions.mode) of a protected image file, scoped like the download route. A file that is not a JPEG, PNG, GIF or WebP image is 404.", + "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": "fileupload field name", + "in": "path", + "name": "field", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "File id", + "in": "path", + "name": "file", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "Form session key; needed for a pending upload", + "in": "header", + "name": "X-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 protected image", + "tags": [ + "admin" + ] + } + }, "/{vendor}/{plugin}/{controller}/{id}/relations/{name}": { "get": { "parameters": [ diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index a617836..9928b82 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -1887,6 +1887,471 @@ export interface paths { patch?: never; trace?: never; }; + "/{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reorder the files of a field + * @description ids must be exactly the field's visible files (attached minus pending removals plus pending uploads); they receive the existing sort_order values in the submitted order, at once. attachMany only, otherwise 403. + */ + post: { + parameters: { + query?: never; + header?: { + /** @description Form session key; needed for pending uploads */ + "X-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 fileupload field name */ + 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}/files/{field}/{file}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Save a file's title and description + * @description Saves at once (not deferred). The field must declare useCaption, otherwise 403. Omitted keys are left unchanged; unknown keys are refused. + */ + put: { + parameters: { + query?: never; + header?: { + /** @description Form session key; needed for a pending upload */ + "X-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 fileupload field name */ + 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 file + * @description Removing an attached file is deferred to the record's next save with the same X-Session-Key; removing a pending upload deletes it at once. + */ + delete: { + parameters: { + query?: never; + header: { + /** @description Form session key */ + "X-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 fileupload field name */ + 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}/files/{field}/{file}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download a protected file + * @description Streams a file of a protected (Public false) relation that belongs to a record the admin may load, or is pending in the admin's own session. Public files are 404. JPEG, PNG, GIF and WebP are served inline with their type; everything else as an application/octet-stream attachment. Responses carry X-Content-Type-Options nosniff, Cache-Control private, no-store and a sandboxing Content-Security-Policy. + */ + get: { + parameters: { + query?: never; + header?: { + /** @description Form session key; needed for a pending upload */ + "X-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 fileupload field name */ + 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}/files/{field}/{file}/thumb": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Thumbnail of a protected image + * @description The preview thumbnail (imageWidth by imageHeight, 240 by 240 by default, in thumbOptions.mode) of a protected image file, scoped like the download route. A file that is not a JPEG, PNG, GIF or WebP image is 404. + */ + get: { + parameters: { + query?: never; + header?: { + /** @description Form session key; needed for a pending upload */ + "X-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 fileupload field name */ + 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}": { parameters: { query?: never; @@ -2267,6 +2732,10 @@ export interface components { }; message: string; }; + "cabana.AdminFileCaptionRequest": { + description?: string; + title?: string; + }; "cabana.AdminIDsRequest": { ids: number[]; }; @@ -2351,6 +2820,10 @@ export interface components { data: components["schemas"]["cabana.FileItem"]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_FileMutationResult": { + data: components["schemas"]["cabana.FileMutationResult"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-cabana_FormView": { data: components["schemas"]["cabana.FormView"]; meta: components["schemas"]["cabana.SuccessMeta"]; @@ -2402,6 +2875,9 @@ export interface components { title: string; url?: string; }; + "cabana.FileMutationResult": { + removed: number; + }; "cabana.FilterOption": { label: string; value: string; diff --git a/docs/backend/forms.md b/docs/backend/forms.md index 4bcecc3..e49e160 100644 --- a/docs/backend/forms.md +++ b/docs/backend/forms.md @@ -136,6 +136,8 @@ The field takes the generic keys plus these WinterCMS keys: Uploads are deferred until the form is saved, on the create form and the update form alike, as WinterCMS's file upload widget does. The admin SPA makes a random session key when it opens a form and sends it in the `X-Session-Key` header with every upload and with the save. The upload stores the file unattached and records a pending binding for that key and the signed-in administrator; the record's create or update save attaches every pending file of the form's fileupload fields inside its own transaction. If the save fails with a 422, the uploads stay pending for the next attempt; if the form is left without saving, the daily `deferred:purge` removes them. A session key is only ever seen by the administrator who used it. +Removing a file is deferred the same way: the file disappears from the form at once and is deleted by the save (a pending upload that is removed is deleted at once). On an attachOne relation, saving a new upload deletes the file it replaces. Captions (with `useCaption`) and the order of an attachMany field are saved at once, as in WinterCMS. After applying the session's work, the save checks `maxFiles` and, for a `required: true` fileupload field, that at least one file is attached; a failure is a 422 on the field and keeps the pending work. Files of a protected relation are shown through authenticated admin routes only; see [Protected files in the admin](../database/attachments.md#protected-files-in-the-admin). + The limits are enforced on the server: the upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB (413 `payload_too_large` past it), and a file that is too large, of a type the field does not allow, or not a valid image in image mode is a 422 on the field. ## What a save may write diff --git a/docs/database/attachments.md b/docs/database/attachments.md index 5e2e959..95b5133 100644 --- a/docs/database/attachments.md +++ b/docs/database/attachments.md @@ -105,6 +105,15 @@ A model declares its attachment relations, the Go form of WinterCMS's `$attachOn Public and protected files live in the same bucket. A protected file (`is_public` false) is kept private by three rules: its disk name is unguessable, the framework never builds a public URL for it, and the host application mounts `attach.StaticHandlerPublic`, which answers 404 for it, or serves the bucket with directory listing turned off. `attach.File.ThumbKey` returns a thumbnail's blob key instead of its URL, so an authenticated route can stream a protected thumbnail itself. +### Protected files in the admin + +A `type: fileupload` field on a protected relation never shows a public URL in the admin; see [Forms](../backend/forms.md#file-uploads). The admin SPA reads such a file through two authenticated admin API routes under the `backend` guard: + +- GET `{prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download` streams the original. +- GET `{prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb` streams the preview thumbnail, built with `attach.File.ThumbKey`; a file that is not a JPEG, PNG, GIF or WebP image has none. + +Both routes look the file up with one query scoped to its record, which the controller's `pact.FormExtendQuery` must let the administrator load, or to an upload pending in the administrator's own form session (`X-Session-Key`). A file of another record, a public file and a file outside that scope all answer 404. Every response carries `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and `Content-Security-Policy: default-src 'none'; sandbox`. Only JPEG, PNG, GIF and WebP are served inline with their own type; any other file, an SVG included, is sent as an `application/octet-stream` attachment, so a stored file never runs script in the admin's origin. + ## Deleting files after commit A rolled-back transaction can restore a row but not the bytes of a deleted blob. Deleting an owner's files is therefore split in two: diff --git a/internal/tools/swagger2openapi/main.go b/internal/tools/swagger2openapi/main.go index 10058f3..673c071 100644 --- a/internal/tools/swagger2openapi/main.go +++ b/internal/tools/swagger2openapi/main.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "os" + "strings" ) func main() { @@ -322,8 +323,17 @@ func convertResponses(res map[string]any, produces []string) map[string]any { converted[k] = v } if schema != nil { + // A Swagger 2.0 file response is binary under the operation's + // media types; any other schema (an error envelope next to a + // binary success) is JSON. + types := produces + if m, ok := schema.(map[string]any); ok && m["type"] == "file" { + schema = map[string]any{"type": "string", "format": "binary"} + } else { + types = jsonTypes(produces) + } content := map[string]any{} - for _, ct := range produces { + for _, ct := range types { content[ct] = map[string]any{"schema": schema} } converted["content"] = content @@ -333,6 +343,21 @@ func convertResponses(res map[string]any, produces []string) map[string]any { return out } +// jsonTypes keeps the JSON media types of produces, or application/json +// when there are none. +func jsonTypes(produces []string) []string { + var out []string + for _, ct := range produces { + if strings.Contains(ct, "json") { + out = append(out, ct) + } + } + if len(out) == 0 { + return []string{"application/json"} + } + return out +} + func convertSecurity(sec map[string]any) map[string]any { out := make(map[string]any, len(sec)) for name, raw := range sec { diff --git a/internal/tools/swagger2openapi/main_test.go b/internal/tools/swagger2openapi/main_test.go index 08a80b6..de696a3 100644 --- a/internal/tools/swagger2openapi/main_test.go +++ b/internal/tools/swagger2openapi/main_test.go @@ -234,3 +234,18 @@ func TestConvertFormData(t *testing.T) { t.Fatalf("form schema = %#v", schema) } } + +func TestConvertFileResponse(t *testing.T) { + res := convertResponses(decode(t, `{ + "200": {"description": "OK", "schema": {"type": "file"}}, + "404": {"description": "Not Found", "schema": {"$ref": "#/definitions/acme.Error"}} + }`), []string{"application/octet-stream"}) + ok := res["200"].(map[string]any)["content"].(map[string]any) + if !reflect.DeepEqual(ok, map[string]any{"application/octet-stream": map[string]any{"schema": map[string]any{"type": "string", "format": "binary"}}}) { + t.Fatalf("file response = %#v", ok) + } + missing := res["404"].(map[string]any)["content"].(map[string]any) + if _, ok := missing["application/json"]; !ok || len(missing) != 1 { + t.Fatalf("error response next to a binary success = %#v", missing) + } +} diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 23d5df4..5014463 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -49,6 +49,14 @@ All paths are relative to `/api/v1`. A controller ID `vendor.plugin.cont | POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. | | 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). | +| DELETE `.../{id}/files/{field}/{file}` | Remove a file: an attached file is removed by the next save with the same `X-Session-Key` (required), a pending upload is deleted at once. | +| 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 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. + +Protected files (a relation with `Public` false) never get a public URL. The download and thumb routes serve only `is_public` false files, with `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and `Content-Security-Policy: default-src 'none'; sandbox`; JPEG, PNG, GIF and WebP are served inline with their type, every other type as an `application/octet-stream` attachment. The thumb route answers 404 for a file that is not one of those images. Every path under the prefix that no API route matches is served by the admin SPA; unmatched API paths return the `not_found` error envelope instead. @@ -167,7 +175,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `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.AdminFileList` / `cabana.AdminFileUpload` | Swag annotations of the file list and upload routes. | +| `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.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 a7cceb9..3328991 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -662,3 +662,118 @@ func AdminFileList() {} // @Failure 422 {object} ErrorEnvelope // @Router /{vendor}/{plugin}/{controller}/{id}/files/{field} [post] func AdminFileUpload() {} + +// AdminFileUpdate documents the caption route of a fileupload field. +// +// @Summary Save a file's title and description +// @Description Saves at once (not deferred). The field must declare useCaption, otherwise 403. Omitted keys are left unchanged; unknown keys are refused. +// @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 field path string true "fileupload field name" +// @Param file path integer true "File id" +// @Param X-Session-Key header string false "Form session key; needed for a pending upload" +// @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}/files/{field}/{file} [put] +func AdminFileUpdate() {} + +// AdminFileRemove documents the removal of a file from a fileupload field. +// +// @Summary Remove a file +// @Description Removing an attached file is deferred to the record's next save with the same X-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 field path string true "fileupload field name" +// @Param file path integer true "File id" +// @Param X-Session-Key header string true "Form session key" +// @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}/files/{field}/{file} [delete] +func AdminFileRemove() {} + +// AdminFileReorder documents the reorder route of an attachMany field. +// +// @Summary Reorder the files of a field +// @Description ids must be exactly the field's visible files (attached minus pending removals plus pending uploads); they receive the existing sort_order values in the submitted order, at once. 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 field path string true "fileupload field name" +// @Param X-Session-Key header string false "Form session key; needed 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}/files/{field}/reorder [post] +func AdminFileReorder() {} + +// AdminFileDownload documents the download of a protected file. +// +// @Summary Download a protected file +// @Description Streams a file of a protected (Public false) relation that belongs to a record the admin may load, or is pending in the admin's own session. Public files are 404. JPEG, PNG, GIF and WebP are served inline with their type; everything else as an application/octet-stream attachment. Responses carry X-Content-Type-Options nosniff, Cache-Control private, no-store and a sandboxing Content-Security-Policy. +// @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 field path string true "fileupload field name" +// @Param file path integer true "File id" +// @Param X-Session-Key header string false "Form session key; needed for a pending upload" +// @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}/files/{field}/{file}/download [get] +func AdminFileDownload() {} + +// AdminFileThumb documents the thumbnail of a protected image. +// +// @Summary Thumbnail of a protected image +// @Description The preview thumbnail (imageWidth by imageHeight, 240 by 240 by default, in thumbOptions.mode) of a protected image file, scoped like the download route. A file that is not a JPEG, PNG, GIF or WebP image is 404. +// @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 field path string true "fileupload field name" +// @Param file path integer true "File id" +// @Param X-Session-Key header string false "Form session key; needed for a pending upload" +// @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}/files/{field}/{file}/thumb [get] +func AdminFileThumb() {} diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index 0fb24f9..443e3e8 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -13,6 +13,8 @@ import ( "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "gocloud.dev/blob" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -20,6 +22,11 @@ import ( // CRUDService runs schema-projected record and bulk operations. type CRUDService struct { DB *gorm.DB + + // bucket deletes the blobs of files a save replaces or removes, after + // commit; tr localizes the file limit messages. Both may be nil. + bucket *blob.Bucket + tr *phrasebook.Translator } // RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is diff --git a/modules/cabana/deferred.go b/modules/cabana/deferred.go index 20f1b6a..f6f7d01 100644 --- a/modules/cabana/deferred.go +++ b/modules/cabana/deferred.go @@ -2,6 +2,7 @@ package cabana import ( "context" + "errors" "net/http" "regexp" "strconv" @@ -11,6 +12,7 @@ import ( "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // SessionKeyHeader carries the admin SPA's form session key (D-02): a @@ -37,60 +39,95 @@ func sessionKeyFrom(r *http.Request) (string, bool, error) { } // commitDeferred applies the file bindings of in.SessionKey to the saved -// target inside the save transaction (D-04). 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; binds attach their pending file, and the -// applied rows are deleted. Bindings of other fields stay for the purge. +// 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. func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, op string, in RecordInput) error { - if cc == nil || len(cc.files) == 0 || in.SessionKey == "" { + if cc == nil || cc.Form == nil || len(cc.files) == 0 { return nil } - principal, _ := bouncer.User(ctx) - if principal == nil || !principal.Backend || principal.ID == 0 { - return nil - } - fields := make([]string, 0, len(cc.files)) + var fields []*compiledFile for _, field := range cc.Form.Fields { if cf := cc.files[field.Name]; cf != nil && contextAllows(cc, cf.name, op) { - fields = append(fields, cf.name) + fields = append(fields, cf) } } - if len(fields) == 0 { + ownerID := primaryText(target) + if len(fields) == 0 || ownerID == "" { return nil } morph, err := lagoon.MorphType(tx, target) if err != nil { return lifecycleFailure(cc, err) } - key := lagoon.DeferredKey{SessionKey: in.SessionKey, AdminID: principal.ID, MasterType: morph} - rows, err := lagoon.DeferredBindings(ctx, tx, key, fields) - if err != nil { - return lifecycleFailure(cc, err) - } - ownerID := primaryText(target) - if ownerID == "" { - return nil - } - applied := make([]uint, 0, len(rows)) - for _, row := range rows { - cf := cc.files[row.MasterField] - if cf == nil || row.SlaveType != lagoon.DeferredFileType || !row.IsBind { - continue + 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 } - if err := s.applyFileBind(ctx, tx, cf, morph, ownerID, row); err != nil { + 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) + } + applied := make([]uint, 0, len(rows)) + for _, row := range rows { + cf := cc.files[row.MasterField] + if cf == nil || row.SlaveType != lagoon.DeferredFileType { + continue + } + 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) + } + if err := lagoon.DeferredForget(ctx, tx, applied); err != nil { return lifecycleFailure(cc, err) } - applied = append(applied, row.ID) } - if err := lagoon.DeferredForget(ctx, tx, applied); err != nil { - return lifecycleFailure(cc, err) + details := map[string]any{} + for _, cf := range fields { + if !cf.required && (!cf.relation.Many || cf.maxFiles == 0) { + continue + } + var n int64 + err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}). + Where("attachment_type = ? AND attachment_id = ? AND field = ?", morph, ownerID, cf.name). + Count(&n).Error + if err != nil { + return lifecycleFailure(cc, err) + } + switch { + case cf.required && n == 0: + details[cf.name] = []string{fileMessage(ctx, s.tr, "required", cf.name, nil)} + case cf.relation.Many && cf.maxFiles > 0 && n > int64(cf.maxFiles): + details[cf.name] = []string{fileMessage(ctx, s.tr, "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)})} + } + } + if len(details) > 0 { + return &ValidationError{Details: details} } return nil } // applyFileBind attaches a pending upload to the owner. A row that is gone -// or already attached somewhere is ignored. +// or already attached somewhere is ignored. On an attachOne field the file +// it replaces is deleted first (WinterCMS's AttachOne::add). func (s CRUDService) applyFileBind(ctx context.Context, tx *gorm.DB, cf *compiledFile, morph, ownerID string, row lagoon.DeferredBinding) error { id, err := strconv.ParseUint(row.SlaveID, 10, 64) if err != nil || id == 0 { @@ -100,11 +137,56 @@ func (s CRUDService) applyFileBind(ctx context.Context, tx *gorm.DB, cf *compile if err != nil || f == nil || f.AttachmentID != "" || f.AttachmentType != "" { return err } - return tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}). + q := tx.Session(&gorm.Session{NewDB: true, Context: ctx}) + if !cf.relation.Many { + var previous []attach.File + err := q.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("attachment_type = ? AND attachment_id = ? AND field = ? AND id <> ?", morph, ownerID, cf.name, f.ID). + Find(&previous).Error + if err != nil { + return err + } + for _, old := range previous { + if err := s.deleteFile(ctx, tx, old); err != nil { + return err + } + } + } + return q.Model(&attach.File{}). Where("id = ?", f.ID). Updates(map[string]any{"attachment_type": morph, "attachment_id": ownerID, "field": cf.name}).Error } +// applyFileUnbind deletes a file attached to this owner and field; a file +// that is not attached there is ignored. +func (s CRUDService) applyFileUnbind(ctx context.Context, tx *gorm.DB, cf *compiledFile, morph, ownerID string, row lagoon.DeferredBinding) error { + id, err := strconv.ParseUint(row.SlaveID, 10, 64) + if err != nil || id == 0 { + return nil + } + var f attach.File + err = tx.Session(&gorm.Session{NewDB: true, Context: ctx}). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND attachment_type = ? AND attachment_id = ? AND field = ?", id, morph, ownerID, cf.name). + Take(&f).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + return s.deleteFile(ctx, tx, f) +} + +// deleteFile deletes a file row now and its blobs after commit. +func (s CRUDService) deleteFile(ctx context.Context, tx *gorm.DB, f attach.File) error { + 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, s.bucket, f) + return nil +} + // primaryText is the saved record's primary key as system_files stores it // in attachment_id (Winter keeps the morph key as a string). func primaryText(model any) string { diff --git a/modules/cabana/field_file.go b/modules/cabana/field_file.go index 8e6d203..e94a073 100644 --- a/modules/cabana/field_file.go +++ b/modules/cabana/field_file.go @@ -2,6 +2,7 @@ package cabana import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -22,6 +23,7 @@ import ( "git.golem15.com/golem15/summercms/modules/phrasebook" "github.com/goccy/go-yaml/ast" "gocloud.dev/blob" + "gocloud.dev/gcerrors" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -734,6 +736,7 @@ func (s *service) writeFileError(w http.ResponseWriter, r *http.Request, cf *com ctx, tr := r.Context(), s.translator() var detail string switch { + case cf == nil: case errors.Is(err, attach.ErrTooLarge): kb := strconv.FormatInt(cf.maxBytes/1024, 10) detail = fileMessage(ctx, tr, "max.file", cf.name, map[string]string{"max": kb}) @@ -768,20 +771,13 @@ func (s *service) writeFileError(w http.ResponseWriter, r *http.Request, cf *com func logFileFailure(r *http.Request, err error) { var ve *ValidationError var missing recordNotFound - var forbidden fileForbidden - if errors.As(err, &ve) || errors.As(err, &missing) || errors.As(err, &forbidden) { + if errors.As(err, &ve) || errors.As(err, &missing) { return } controller := r.PathValue("vendor") + "." + r.PathValue("plugin") + "." + r.PathValue("controller") slog.Default().ErrorContext(r.Context(), "cabana: file route failed", "controller", controller, "field", r.PathValue("field"), "error", err) } -// fileForbidden is a file operation the field does not declare (a caption -// without useCaption, a reorder on attachOne): 403. -type fileForbidden struct{} - -func (fileForbidden) Error() string { return "cabana: file operation not declared" } - // bodyReader remembers the first read error of the request body other than // EOF, so a failed upload tells a malformed body from a storage failure. type bodyReader struct { @@ -860,3 +856,419 @@ func lockFile(ctx context.Context, tx *gorm.DB, id uint) (*attach.File, error) { } return &f, nil } + +// AdminFileCaptionRequest is the body of the file caption route. A nil +// field is left unchanged; unknown keys are refused. +type AdminFileCaptionRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` +} + +// pathFileID parses {file}; anything but a positive integer is not found. +func pathFileID(r *http.Request) (uint, error) { + n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("file")), 10, 64) + if err != nil || n == 0 { + return 0, recordNotFound{} + } + return uint(n), nil +} + +// findFile loads one file of the scope with a single parent-scoped query: +// it must be attached to the scope's owner and field, or be a pending upload +// bound to the scope's session key. Anything else, a file of another record +// included, is recordNotFound (never 403). +func (sc *fileScope) findFile(ctx context.Context, tx *gorm.DB, id uint, lock bool) (*attach.File, error) { + fresh := func() *gorm.DB { return tx.Session(&gorm.Session{NewDB: true, Context: ctx}) } + var group *gorm.DB + if sc.ownerID > 0 { + group = fresh().Where("attachment_type = ? AND attachment_id = ? AND field = ?", sc.morph, sc.ownerText(), sc.file.name) + } + if sc.hasKey { + pending := "(attachment_id IS NULL OR attachment_id = '') AND CAST(id AS TEXT) IN (?)" + slaves := lagoon.DeferredSlaves(tx, sc.key, sc.file.name, lagoon.DeferredFileType, true) + if group == nil { + group = fresh().Where(pending, slaves) + } else { + group = group.Or(pending, slaves) + } + } + if group == nil { + return nil, recordNotFound{} + } + q := fresh().Where("id = ?", id).Where(group) + if lock { + q = q.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var f attach.File + err := q.Take(&f).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, recordNotFound{} + } + if err != nil { + return nil, err + } + return &f, nil +} + +// 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 { + db, err := s.db() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return false + } + 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 + } + return fn(ctx, tx, sc) + }) + if err != nil { + s.writeFileError(w, r, cc.files[r.PathValue("field")], 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) + if err == nil && !ok { + err = &ValidationError{Details: map[string]any{"session_key": []string{"The session key field is required."}}} + } + if err != nil { + writeCRUDError(w, err) + return false + } + return 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) { + keys := attach.BlobKeys(f) + lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { + if bucket == nil { + slog.Default().WarnContext(ctx, "cabana: no storage bucket; blobs of a deleted file were kept", "file_id", f.ID) + return + } + if err := attach.DeleteKeys(ctx, bucket, keys); err != nil { + slog.Default().WarnContext(ctx, "cabana: deleting a file's blobs failed", "file_id", f.ID, "error", err) + } + }) +} + +// fileRemove serves DELETE .../{id}/files/{field}/{file}: it defers the +// 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) + if err != nil { + writeCRUDError(w, err) + return + } + 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 { + 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) + } + }) +} + +// jsonCap is the body cap of the JSON file routes: http.body_limits. +// default_bytes, or 1 MiB when it is not configured. +func (s *service) jsonCap() int64 { + if s != nil && s.defaultBytes > 0 { + return s.defaultBytes + } + return 1 << 20 +} + +// decodeStrictBody decodes a capped JSON body into dest with unknown keys +// and trailing data refused. +func (s *service) decodeStrictBody(w http.ResponseWriter, r *http.Request, dest any) error { + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap())) + dec.DisallowUnknownFields() + if err := dec.Decode(dest); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + return err + } + return invalidBody() + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + return invalidBody() + } + return nil +} + +// fileUpdate serves PUT .../{id}/files/{field}/{file}: it saves a file's +// 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) + 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, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error { + f, err := sc.findFile(ctx, tx, fileID, true) + if 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) + } + }) +} + +// 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 + } + 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, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error { + files, _, err := sc.visibleFiles(tx) + if 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) + } + }) +} + +// fileDownload serves GET .../{id}/files/{field}/{file}/download. +func (s *service) fileDownload(w http.ResponseWriter, r *http.Request) { + s.serveProtectedFile(w, r, false) +} + +// fileThumb serves GET .../{id}/files/{field}/{file}/thumb. +func (s *service) fileThumb(w http.ResponseWriter, r *http.Request) { + s.serveProtectedFile(w, r, true) +} + +// serveProtectedFile streams a protected (is_public false) file or its +// thumbnail (D-10). The file must belong to a record the admin may load +// through FormExtendQuery, or be pending in the admin's own session; a +// public file, a file of another record and a thumbnail of a non-image are +// 404. Only JPEG, PNG, GIF and WebP are served inline; everything else is an +// 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 { + 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, 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 + } + 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) + }) +} + +// rfc5987 percent-encodes a file name for a filename* parameter: only +// RFC 5987 attr-char bytes stay literal. +func rfc5987(name string) string { + const hex = "0123456789ABCDEF" + var b strings.Builder + for i := 0; i < len(name); i++ { + c := name[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', + strings.IndexByte("!#$&+-.^_`|~", c) >= 0: + b.WriteByte(c) + default: + b.WriteByte('%') + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&15]) + } + } + if b.Len() == 0 { + return "file" + } + return b.String() +} diff --git a/modules/cabana/fileupload_smoke_test.go b/modules/cabana/fileupload_smoke_test.go index 2267e5b..b257ada 100644 --- a/modules/cabana/fileupload_smoke_test.go +++ b/modules/cabana/fileupload_smoke_test.go @@ -1,7 +1,9 @@ package cabana_test import ( + "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -9,6 +11,8 @@ import ( "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "gorm.io/gorm" ) func fileList(t *testing.T, rec *httptest.ResponseRecorder) []cabana.FileItem { @@ -116,3 +120,189 @@ func TestFileuploadSmokeForeignAdmin(t *testing.T) { t.Fatalf("owner's binding rows = %d, want 1", n) } } + +func (e *conformEnv) createGadget(t *testing.T, name, key string) uint { + t.Helper() + payload, _ := json.Marshal(map[string]any{"name": name}) + headers := map[string]string{} + if key != "" { + headers[cabana.SessionKeyHeader] = key + } + rec := e.sendWith(t, http.MethodPost, "/acme/conform/gadgets", payload, "application/json", headers) + if rec.Code != http.StatusCreated { + t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String()) + } + return dataID(t, rec.Body.Bytes()) +} + +func (e *conformEnv) saveGadget(t *testing.T, id uint, name, key string) *httptest.ResponseRecorder { + t.Helper() + payload, _ := json.Marshal(map[string]any{"name": name}) + return e.sendWith(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", id), payload, "application/json", map[string]string{cabana.SessionKeyHeader: key}) +} + +func (e *conformEnv) storedFile(t *testing.T, id uint) (attach.File, bool) { + t.Helper() + var f attach.File + err := e.db.Where("id = ?", id).Take(&f).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return attach.File{}, false + } + if err != nil { + t.Fatal(err) + } + return f, true +} + +func (e *conformEnv) blobExists(t *testing.T, diskName string) bool { + t.Helper() + ok, err := e.bucket.Exists(context.Background(), attach.BlobKey(diskName)) + if err != nil { + t.Fatal(err) + } + return ok +} + +// TestFileuploadSmokeRemoveCancelsPending removes a pending upload: its +// row is deleted and, after commit, its blob. +func TestFileuploadSmokeRemoveCancelsPending(t *testing.T) { + env := newConformEnv(t) + env.loginAs(t, env.login) + key := newSessionKey(t) + up := env.upload(t, 0, "photos", "photo.png", conformPNG(t), key) + if up.Code != http.StatusCreated { + t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String()) + } + id := dataID(t, up.Body.Bytes()) + f, ok := env.storedFile(t, id) + if !ok || !env.blobExists(t, f.DiskName) { + t.Fatal("upload left no row or blob") + } + rec := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", id), nil, "", map[string]string{cabana.SessionKeyHeader: key}) + if rec.Code != http.StatusOK { + t.Fatalf("remove status=%d body=%s", rec.Code, rec.Body.String()) + } + if _, ok := env.storedFile(t, id); ok { + t.Fatal("cancelled upload row still exists") + } + if env.blobExists(t, f.DiskName) { + t.Fatal("cancelled upload blob still exists") + } + if n := env.bindingCount(t, key); n != 0 { + t.Fatalf("bindings after cancel = %d", n) + } + // The same file again is out of scope. + again := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", id), nil, "", map[string]string{cabana.SessionKeyHeader: key}) + if again.Code != http.StatusNotFound { + t.Fatalf("second remove status=%d", again.Code) + } +} + +// TestFileuploadSmokeAttachOneReplace saves a second file into an attachOne +// field: the first file's row and blob are gone after the save, and a +// deferred removal of an attached file applies on the next save. +func TestFileuploadSmokeAttachOneReplace(t *testing.T) { + env := newConformEnv(t) + env.loginAs(t, env.login) + gadget := env.createGadget(t, "replace-"+env.stamp, "") + + first := newSessionKey(t) + upA := env.upload(t, gadget, "manual", "a.txt", []byte("first manual\n"), first) + if upA.Code != http.StatusCreated { + t.Fatalf("upload a status=%d body=%s", upA.Code, upA.Body.String()) + } + if rec := env.saveGadget(t, gadget, "replace-"+env.stamp, first); rec.Code != http.StatusOK { + t.Fatalf("save a status=%d body=%s", rec.Code, rec.Body.String()) + } + a, _ := env.storedFile(t, dataID(t, upA.Body.Bytes())) + if got := env.listFiles(t, gadget, "manual", ""); len(got) != 1 || got[0].ID != a.ID || got[0].URL != "" { + t.Fatalf("after first save = %#v", got) + } + + second := newSessionKey(t) + upB := env.upload(t, gadget, "manual", "b.txt", []byte("second manual\n"), second) + if upB.Code != http.StatusCreated { + t.Fatalf("upload b status=%d body=%s", upB.Code, upB.Body.String()) + } + if rec := env.saveGadget(t, gadget, "replace-"+env.stamp, second); rec.Code != http.StatusOK { + t.Fatalf("save b status=%d body=%s", rec.Code, rec.Body.String()) + } + got := env.listFiles(t, gadget, "manual", "") + if len(got) != 1 || got[0].ID != dataID(t, upB.Body.Bytes()) { + t.Fatalf("after replace = %#v", got) + } + if _, ok := env.storedFile(t, a.ID); ok { + t.Fatal("replaced attachOne row still exists") + } + if env.blobExists(t, a.DiskName) { + t.Fatal("replaced attachOne blob still exists") + } + + // A deferred removal hides the file in the session and deletes it on save. + third := newSessionKey(t) + b, _ := env.storedFile(t, got[0].ID) + if rec := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d", gadget, b.ID), nil, "", map[string]string{cabana.SessionKeyHeader: third}); rec.Code != http.StatusOK { + t.Fatalf("remove status=%d body=%s", rec.Code, rec.Body.String()) + } + if len(env.listFiles(t, gadget, "manual", third)) != 0 || len(env.listFiles(t, gadget, "manual", "")) != 1 { + t.Fatal("deferred removal is not scoped to its session") + } + if rec := env.saveGadget(t, gadget, "replace-"+env.stamp, third); rec.Code != http.StatusOK { + t.Fatalf("save removal status=%d body=%s", rec.Code, rec.Body.String()) + } + if _, ok := env.storedFile(t, b.ID); ok || env.blobExists(t, b.DiskName) { + t.Fatal("deferred removal did not delete the file and its blob") + } +} + +// TestProtectedFileSmoke downloads protected files through the admin route: +// a file of another record is 404, a public file is 404, and an SVG stored +// in file mode is an octet-stream attachment with nosniff. +func TestProtectedFileSmoke(t *testing.T) { + env := newConformEnv(t) + env.loginAs(t, env.login) + owner := env.createGadget(t, "owner-"+env.stamp, "") + other := env.createGadget(t, "other-"+env.stamp, "") + key := newSessionKey(t) + svg := []byte(``) + up := env.upload(t, owner, "manual", "logo one.svg", svg, key) + if up.Code != http.StatusCreated { + t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String()) + } + if rec := env.saveGadget(t, owner, "owner-"+env.stamp, key); rec.Code != http.StatusOK { + t.Fatalf("save status=%d body=%s", rec.Code, rec.Body.String()) + } + fileID := dataID(t, up.Body.Bytes()) + + rec := env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/download", owner, fileID), nil, "", nil) + h := rec.Header() + if rec.Code != http.StatusOK || h.Get("Content-Type") != "application/octet-stream" || h.Get("X-Content-Type-Options") != "nosniff" || + h.Get("Content-Disposition") != "attachment; filename*=UTF-8''logo%20one.svg" || h.Get("Cache-Control") != "private, no-store" || + h.Get("Content-Security-Policy") != "default-src 'none'; sandbox" || rec.Body.String() != string(svg) { + t.Fatalf("svg download status=%d headers=%v body=%q", rec.Code, h, rec.Body.String()) + } + if thumb := env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/thumb", owner, fileID), nil, "", nil); thumb.Code != http.StatusNotFound { + t.Fatalf("thumb of a non-image status=%d", thumb.Code) + } + for _, path := range []string{ + fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/download", other, fileID), + fmt.Sprintf("/acme/conform/gadgets/%d/files/photos/%d/download", owner, fileID), + fmt.Sprintf("/acme/conform/gadgets/0/files/manual/%d/download", fileID), + } { + if rec := env.sendWith(t, http.MethodGet, path, nil, "", map[string]string{cabana.SessionKeyHeader: key}); rec.Code != http.StatusNotFound { + t.Fatalf("%s status=%d, want 404", path, rec.Code) + } + } + if rec := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d", other, fileID), nil, "", map[string]string{cabana.SessionKeyHeader: key}); rec.Code != http.StatusNotFound { + t.Fatalf("remove through another record status=%d", rec.Code) + } + + // A public file is never served by the protected route. + pub := env.upload(t, owner, "photos", "photo.png", conformPNG(t), key) + if pub.Code != http.StatusCreated { + t.Fatalf("public upload status=%d body=%s", pub.Code, pub.Body.String()) + } + if rec := env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/photos/%d/download", owner, dataID(t, pub.Body.Bytes())), nil, "", map[string]string{cabana.SessionKeyHeader: key}); rec.Code != http.StatusNotFound { + t.Fatalf("public file through the protected route status=%d", rec.Code) + } +} diff --git a/modules/cabana/http.go b/modules/cabana/http.go index d1e2c31..2d23615 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -279,6 +279,16 @@ func (s *service) mount(r pact.Router) { // record being created in the X-Session-Key session. g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload)) constrainFile(g) + g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", requireAjax(s.fileReorder)) + constrainFile(g) + g.Put("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", requireAjax(s.fileUpdate)) + constrainFileID(g) + g.Delete("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", requireAjax(s.fileRemove)) + constrainFileID(g) + g.Get("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download", s.fileDownload) + constrainFileID(g) + g.Get("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb", s.fileThumb) + constrainFileID(g) }) // The SPA shell: public, no guard. ServeMux prefers every API pattern // above over the {path...} wildcard. @@ -309,6 +319,11 @@ func constrainFile(g pact.Router) { g.Where("field", "[A-Za-z_][A-Za-z0-9_]*") } +func constrainFileID(g pact.Router) { + constrainFile(g) + g.Where("file", "[0-9]+") +} + func constrainNested(g pact.Router) { constrainController(g) g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*") @@ -787,7 +802,7 @@ func (s *service) crud() (CRUDService, error) { if err != nil { return CRUDService{}, err } - return CRUDService{DB: db}, nil + return CRUDService{DB: db, bucket: s.bucket(), tr: s.translator()}, nil } func (s *service) list(w http.ResponseWriter, r *http.Request) { diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index c4d1a2a..d5a5501 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -45,6 +45,22 @@ type conformCase struct { ref string call func(t *testing.T, env *conformEnv) *httptest.ResponseRecorder decode func(dec *json.Decoder) error + // raw, when set, checks a binary response instead of decoding JSON; + // admin.json must document the success as binary. + raw func(t *testing.T, rec *httptest.ResponseRecorder) +} + +// binaryFile checks a protected file response: status, content type and +// the D-10 headers. +func binaryFile(contentType string) func(t *testing.T, rec *httptest.ResponseRecorder) { + return func(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + h := rec.Header() + if h.Get("Content-Type") != contentType || h.Get("X-Content-Type-Options") != "nosniff" || + h.Get("Cache-Control") != "private, no-store" || !strings.Contains(h.Get("Content-Security-Policy"), "sandbox") || rec.Body.Len() == 0 { + t.Fatalf("protected file headers=%v len=%d", h, rec.Body.Len()) + } + } } func into[T any]() func(*json.Decoder) error { @@ -66,33 +82,33 @@ func TestPhase10OpenAPIConformance(t *testing.T) { cases := []conformCase{ {"POST /auth/login", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodPost, "/auth/login", map[string]string{"login": e.login, "password": adminTestPassword}, false) - }, into[cabana.Envelope[cabana.AdminLoginData]]()}, + }, into[cabana.Envelope[cabana.AdminLoginData]](), nil}, {"POST /auth/refresh", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { rec := e.send(t, http.MethodPost, "/auth/refresh", nil, true) e.token = accessToken(t, rec.Body.Bytes()) return rec - }, into[cabana.Envelope[cabana.AdminLoginData]]()}, + }, into[cabana.Envelope[cabana.AdminLoginData]](), nil}, {"GET /auth/me", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/auth/me", nil, true) - }, into[cabana.Envelope[cabana.AdminProfile]]()}, + }, into[cabana.Envelope[cabana.AdminProfile]](), nil}, {"GET /lang", 200, "cabana.Envelope-cabana_LangBundle", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/lang", nil, false) - }, into[cabana.Envelope[cabana.LangBundle]]()}, + }, into[cabana.Envelope[cabana.LangBundle]](), nil}, {"GET /navigation", 200, "cabana.Envelope-array_cabana_NavigationEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/navigation", nil, true) - }, into[cabana.Envelope[[]cabana.NavigationEntry]]()}, + }, into[cabana.Envelope[[]cabana.NavigationEntry]](), nil}, {"GET /settings", 200, "cabana.Envelope-array_cabana_SettingsEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/settings", nil, true) - }, into[cabana.Envelope[[]cabana.SettingsEntry]]()}, + }, into[cabana.Envelope[[]cabana.SettingsEntry]](), nil}, {"GET /settings/{code}/schema", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/settings/conform/schema", nil, true) - }, into[cabana.Envelope[cabana.FormView]]()}, + }, into[cabana.Envelope[cabana.FormView]](), nil}, {"GET /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/settings/conform", nil, true) - }, into[cabana.Envelope[cabana.SettingsResult]]()}, + }, into[cabana.Envelope[cabana.SettingsResult]](), nil}, {"PUT /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true) - }, into[cabana.Envelope[cabana.SettingsResult]]()}, + }, into[cabana.Envelope[cabana.SettingsResult]](), nil}, {"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { rec := e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true) var body cabana.Envelope[cabana.ListSchema] @@ -108,22 +124,24 @@ func TestPhase10OpenAPIConformance(t *testing.T) { e.assertPluginAsset(t, body.Data.Assets.Scripts[0], "text/javascript; charset=utf-8") e.assertPluginAsset(t, body.Data.Assets.Styles[0], "text/css; charset=utf-8") return rec - }, into[cabana.Envelope[cabana.ListSchema]]()}, + }, into[cabana.Envelope[cabana.ListSchema]](), nil}, {"GET /{vendor}/{plugin}/{controller}/schema/form", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/form", nil, true) - }, into[cabana.Envelope[cabana.FormView]]()}, + }, into[cabana.Envelope[cabana.FormView]](), nil}, {"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", 200, "cabana.Envelope-cabana_RelationSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/relation/members", nil, true) - }, into[cabana.Envelope[cabana.RelationSchema]]()}, + }, into[cabana.Envelope[cabana.RelationSchema]](), nil}, {"GET /{vendor}/{plugin}/{controller}/fields/{field}/options", 200, "cabana.ListEnvelope-array_cabana_RelationOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/acme/conform/gadgets/fields/group/options?search="+e.stamp, nil, true) - }, into[cabana.ListEnvelope[[]cabana.RelationOption]]()}, + }, into[cabana.ListEnvelope[[]cabana.RelationOption]](), nil}, {"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", 200, "cabana.Envelope-array_cabana_FilterOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/acme/conform/gadgets/filters/grouped/options", nil, true) - }, into[cabana.Envelope[[]cabana.FilterOption]]()}, + }, into[cabana.Envelope[[]cabana.FilterOption]](), nil}, {"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", 201, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { - return e.upload(t, 0, "photos", "photo.png", conformPNG(t), e.sessionKey) - }, into[cabana.Envelope[cabana.FileItem]]()}, + rec := e.upload(t, 0, "photos", "photo.png", conformPNG(t), e.sessionKey) + e.photoID = dataID(t, rec.Body.Bytes()) + return rec + }, into[cabana.Envelope[cabana.FileItem]](), nil}, {"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { rec := e.sendWith(t, http.MethodGet, "/acme/conform/gadgets/0/files/photos", nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey}) var body cabana.Envelope[[]cabana.FileItem] @@ -131,12 +149,34 @@ func TestPhase10OpenAPIConformance(t *testing.T) { t.Fatalf("pending file list = %s (%v)", rec.Body.String(), err) } return rec - }, into[cabana.Envelope[[]cabana.FileItem]]()}, + }, into[cabana.Envelope[[]cabana.FileItem]](), nil}, + {"POST /{vendor}/{plugin}/{controller}/{id}/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.photoID}}) + return e.sendWith(t, http.MethodPost, "/acme/conform/gadgets/0/files/photos/reorder", body, "application/json", map[string]string{cabana.SessionKeyHeader: e.sessionKey}) + }, into[cabana.Envelope[[]cabana.FileItem]](), nil}, + {"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", e.photoID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey}) + }, into[cabana.Envelope[cabana.FileMutationResult]](), nil}, {"POST /{vendor}/{plugin}/{controller}", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID}, true) e.gadgetID = dataID(t, rec.Body.Bytes()) return rec - }, into[cabana.RecordEnvelope]()}, + }, into[cabana.RecordEnvelope](), nil}, + {"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + up := e.upload(t, e.gadgetID, "manual", "manual.png", conformPNG(t), e.sessionKey) + if up.Code != http.StatusCreated { + t.Fatalf("manual upload status=%d body=%s", up.Code, up.Body.String()) + } + e.manualID = dataID(t, up.Body.Bytes()) + body, _ := json.Marshal(map[string]any{"title": "Manual " + e.stamp}) + return e.sendWith(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d", e.gadgetID, e.manualID), body, "application/json", map[string]string{cabana.SessionKeyHeader: e.sessionKey}) + }, into[cabana.Envelope[cabana.FileItem]](), nil}, + {"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/download", e.gadgetID, e.manualID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey}) + }, nil, binaryFile("image/png")}, + {"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/thumb", e.gadgetID, e.manualID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey}) + }, nil, binaryFile("image/png")}, {"POST /{vendor}/{plugin}/{controller}/widgets/{field}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { rec := e.send(t, http.MethodPost, "/acme/conform/gadgets/widgets/lookup", map[string]any{"record_id": e.gadgetID, "values": map[string]any{"name": "x", "active": false}}, true) // The fixture action also returns active, which is outside the @@ -149,7 +189,7 @@ func TestPhase10OpenAPIConformance(t *testing.T) { t.Fatalf("widget fill = %#v, want only name", body.Data.Fill) } return rec - }, into[cabana.Envelope[cabana.AdminActionResult]]()}, + }, into[cabana.Envelope[cabana.AdminActionResult]](), nil}, {"GET /{vendor}/{plugin}/{controller}/partials/{name}", 200, "cabana.Envelope-cabana_PartialView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { rec := e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/partials/summary?id=%d", e.gadgetID), nil, true) if !strings.Contains(rec.Body.String(), `"text":"gadget-`+e.stamp+`"`) { @@ -160,41 +200,41 @@ func TestPhase10OpenAPIConformance(t *testing.T) { t.Fatalf("header partial status=%d body=%s", header.Code, header.Body.String()) } return rec - }, into[cabana.Envelope[cabana.PartialView]]()}, + }, into[cabana.Envelope[cabana.PartialView]](), nil}, {"GET /{vendor}/{plugin}/{controller}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true) - }, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()}, + }, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil}, {"GET /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true) - }, into[cabana.RecordEnvelope]()}, + }, into[cabana.RecordEnvelope](), nil}, {"PUT /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), map[string]any{"name": "gadget-" + e.stamp + "-renamed", "group": nil}, true) - }, into[cabana.RecordEnvelope]()}, + }, into[cabana.RecordEnvelope](), nil}, {"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/candidates?search=%s", e.gadgetID, e.stamp), nil, true) - }, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()}, + }, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil}, {"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/link", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true) - }, into[cabana.Envelope[cabana.RelationMutationResult]]()}, + }, into[cabana.Envelope[cabana.RelationMutationResult]](), nil}, {"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members", e.gadgetID), nil, true) - }, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()}, + }, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil}, {"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true) - }, into[cabana.Envelope[cabana.RelationMutationResult]]()}, + }, into[cabana.Envelope[cabana.RelationMutationResult]](), nil}, {"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true) - }, into[cabana.Envelope[cabana.AdminActionResult]]()}, + }, into[cabana.Envelope[cabana.AdminActionResult]](), nil}, {"POST /{vendor}/{plugin}/{controller}/bulk-delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true) return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true) - }, into[cabana.Envelope[cabana.BulkResult]]()}, + }, into[cabana.Envelope[cabana.BulkResult]](), nil}, {"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true) - }, into[cabana.Envelope[cabana.BulkResult]]()}, + }, into[cabana.Envelope[cabana.BulkResult]](), nil}, {"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodPost, "/auth/logout", nil, true) - }, into[cabana.Envelope[cabana.AdminLogoutData]]()}, + }, into[cabana.Envelope[cabana.AdminLogoutData]](), nil}, } inventory := map[string]bool{} @@ -223,12 +263,19 @@ func TestPhase10OpenAPIConformance(t *testing.T) { if rec.Code != tc.status { t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String()) } + method, path, _ := strings.Cut(tc.key, " ") + if tc.raw != nil { + tc.raw(t, rec) + if !spec.binary(path, strings.ToLower(method), fmt.Sprint(tc.status)) { + t.Fatalf("admin.json does not document %s %d as binary", tc.key, tc.status) + } + return + } dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes())) dec.DisallowUnknownFields() if err := tc.decode(dec); err != nil { t.Fatalf("body does not match its documented type: %v\n%s", err, rec.Body.String()) } - method, path, _ := strings.Cut(tc.key, " ") got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status)) if got != tc.ref { t.Fatalf("admin.json documents %q for %s %d, the handler writes %s", got, tc.key, tc.status, tc.ref) @@ -245,13 +292,20 @@ type conformSpecDoc struct { Responses map[string]struct { Content map[string]struct { Schema struct { - Ref string `json:"$ref"` + Ref string `json:"$ref"` + Type string `json:"type"` + Format string `json:"format"` } `json:"schema"` } `json:"content"` } `json:"responses"` } `json:"paths"` } +func (d conformSpecDoc) binary(path, method, status string) bool { + schema := d.Paths[path][method].Responses[status].Content["application/octet-stream"].Schema + return schema.Type == "string" && schema.Format == "binary" +} + func (d conformSpecDoc) ref(path, method, status string) string { ref := d.Paths[path][method].Responses[status].Content["application/json"].Schema.Ref return strings.TrimPrefix(ref, "#/components/schemas/") @@ -282,6 +336,8 @@ type conformEnv struct { token string stamp string sessionKey string + photoID uint + manualID uint groupID uint memberID uint gadgetID uint @@ -491,7 +547,7 @@ type conformGadget struct { func (conformGadget) TableName() string { return "cabana_conform_gadgets" } func (conformGadget) MorphName() string { return "acme.conform.gadget" } func (conformGadget) AttachRelations() []attach.Relation { - return []attach.Relation{{Name: "photos", Many: true, Public: true}} + return []attach.Relation{{Name: "photos", Many: true, Public: true}, {Name: "manual"}} } func (conformGadget) Fillable() []string { return []string{"name", "active"} } func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} } @@ -733,6 +789,12 @@ update: maxFilesize: 0.5 thumbOptions: mode: crop + manual: + label: Manual + type: fileupload + fileTypes: [pdf, png, svg, txt] + useCaption: true + context: update `), "models/settings/fields.yaml": file(`fields: enabled: diff --git a/modules/cabana/phase10_coverage_test.go b/modules/cabana/phase10_coverage_test.go index 7aa8a90..9531c24 100644 --- a/modules/cabana/phase10_coverage_test.go +++ b/modules/cabana/phase10_coverage_test.go @@ -89,6 +89,7 @@ func TestPhase10Coverage(t *testing.T) { sort.Strings(unsafe) want := []string{ "DELETE /{vendor}/{plugin}/{controller}/{id}", + "DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", "POST /auth/login", "POST /auth/logout", "POST /auth/refresh", @@ -97,13 +98,15 @@ func TestPhase10Coverage(t *testing.T) { "POST /{vendor}/{plugin}/{controller}/toolbar/{action}", "POST /{vendor}/{plugin}/{controller}/widgets/{field}", "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", + "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", "PUT /settings/{code}", "PUT /{vendor}/{plugin}/{controller}/{id}", + "PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", } if strings.Join(unsafe, "\n") != strings.Join(want, "\n") { - t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 12 besides login):\n%s", strings.Join(unsafe, "\n")) + t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 15 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 d7478e6..ce0b69f 100644 --- a/modules/cabana/phase10_csrf_test.go +++ b/modules/cabana/phase10_csrf_test.go @@ -67,9 +67,10 @@ func TestPhase10CSRF(t *testing.T) { }) } // refresh, logout, settings put, create, bulk-delete, widget action, - // toolbar action, update, delete, link, unlink, file upload - if unsafe != 12 { - t.Fatalf("walked %d state-changing routes, want 12: %v", unsafe, router.order) + // toolbar action, update, delete, link, unlink, file upload, file + // reorder, file caption, file remove + if unsafe != 15 { + t.Fatalf("walked %d state-changing routes, want 15: %v", unsafe, router.order) } loginHandler := router.handlers[login] diff --git a/modules/cabana/security_coverage_test.go b/modules/cabana/security_coverage_test.go index c422e8d..45b1e73 100644 --- a/modules/cabana/security_coverage_test.go +++ b/modules/cabana/security_coverage_test.go @@ -64,6 +64,11 @@ var phase09Routes = []adminRoute{ {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"}, {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"}, + {key: "PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}"}, + {key: "DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}"}, + {key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download"}, + {key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb"}, {key: "GET /assets/{vendor}/{plugin}/{file...}", public: true, spa: true}, {key: "GET ", public: true, spa: true}, {key: "GET /{path...}", public: true, spa: true}, @@ -290,6 +295,11 @@ func phase09ProtectedCalls() []phase09Call { {"relation-unlink", (*service).relationUnlink}, {"file-list", (*service).fileList}, {"file-upload", (*service).fileUpload}, + {"file-reorder", (*service).fileReorder}, + {"file-update", (*service).fileUpdate}, + {"file-remove", (*service).fileRemove}, + {"file-download", (*service).fileDownload}, + {"file-thumb", (*service).fileThumb}, {"settings-schema", (*service).settingsSchema}, {"settings-get", (*service).settingsGet}, {"settings-put", (*service).settingsPut},