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
This commit is contained in:
Jakub Zych
2026-10-02 18:11:56 +02:00
parent 044e0450ef
commit e54fd257ee
17 changed files with 2237 additions and 82 deletions

View File

@@ -29,6 +29,17 @@
], ],
"type": "object" "type": "object"
}, },
"cabana.AdminFileCaptionRequest": {
"properties": {
"description": {
"type": "string"
},
"title": {
"type": "string"
}
},
"type": "object"
},
"cabana.AdminIDsRequest": { "cabana.AdminIDsRequest": {
"properties": { "properties": {
"ids": { "ids": {
@@ -353,6 +364,21 @@
], ],
"type": "object" "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": { "cabana.Envelope-cabana_FormView": {
"properties": { "properties": {
"data": { "data": {
@@ -538,6 +564,17 @@
], ],
"type": "object" "type": "object"
}, },
"cabana.FileMutationResult": {
"properties": {
"removed": {
"type": "integer"
}
},
"required": [
"removed"
],
"type": "object"
},
"cabana.FilterOption": { "cabana.FilterOption": {
"properties": { "properties": {
"label": { "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}": { "/{vendor}/{plugin}/{controller}/{id}/relations/{name}": {
"get": { "get": {
"parameters": [ "parameters": [

View File

@@ -1887,6 +1887,471 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}": { "/{vendor}/{plugin}/{controller}/{id}/relations/{name}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2267,6 +2732,10 @@ export interface components {
}; };
message: string; message: string;
}; };
"cabana.AdminFileCaptionRequest": {
description?: string;
title?: string;
};
"cabana.AdminIDsRequest": { "cabana.AdminIDsRequest": {
ids: number[]; ids: number[];
}; };
@@ -2351,6 +2820,10 @@ export interface components {
data: components["schemas"]["cabana.FileItem"]; data: components["schemas"]["cabana.FileItem"];
meta: components["schemas"]["cabana.SuccessMeta"]; meta: components["schemas"]["cabana.SuccessMeta"];
}; };
"cabana.Envelope-cabana_FileMutationResult": {
data: components["schemas"]["cabana.FileMutationResult"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_FormView": { "cabana.Envelope-cabana_FormView": {
data: components["schemas"]["cabana.FormView"]; data: components["schemas"]["cabana.FormView"];
meta: components["schemas"]["cabana.SuccessMeta"]; meta: components["schemas"]["cabana.SuccessMeta"];
@@ -2402,6 +2875,9 @@ export interface components {
title: string; title: string;
url?: string; url?: string;
}; };
"cabana.FileMutationResult": {
removed: number;
};
"cabana.FilterOption": { "cabana.FilterOption": {
label: string; label: string;
value: string; value: string;

View File

@@ -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. 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. 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 ## What a save may write

View File

@@ -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. 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 ## 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: 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:

View File

@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"strings"
) )
func main() { func main() {
@@ -322,8 +323,17 @@ func convertResponses(res map[string]any, produces []string) map[string]any {
converted[k] = v converted[k] = v
} }
if schema != nil { 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{} content := map[string]any{}
for _, ct := range produces { for _, ct := range types {
content[ct] = map[string]any{"schema": schema} content[ct] = map[string]any{"schema": schema}
} }
converted["content"] = content converted["content"] = content
@@ -333,6 +343,21 @@ func convertResponses(res map[string]any, produces []string) map[string]any {
return out 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 { func convertSecurity(sec map[string]any) map[string]any {
out := make(map[string]any, len(sec)) out := make(map[string]any, len(sec))
for name, raw := range sec { for name, raw := range sec {

View File

@@ -234,3 +234,18 @@ func TestConvertFormData(t *testing.T) {
t.Fatalf("form schema = %#v", schema) 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)
}
}

View File

@@ -49,6 +49,14 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. | | 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`. | | GET `.../{id}/files/{field}` | The files of a `type: fileupload` field: attached files minus the session's pending removals, plus its pending uploads, in `sort_order`. `{id}` 0 is the record being created and needs `X-Session-Key`. |
| POST `.../{id}/files/{field}` | Upload one multipart `file_data` part; it is bound to the `X-Session-Key` session (required) and attached by the record's next save. Answers 201 with the pending `cabana.FileItem`. | | POST `.../{id}/files/{field}` | Upload one multipart `file_data` part; it is bound to the `X-Session-Key` session (required) and attached by the record's next save. Answers 201 with the pending `cabana.FileItem`. |
| PUT `.../{id}/files/{field}/{file}` | Save a file's `title` and `description` at once; the field must declare `useCaption` (403 otherwise). |
| 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. 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.RecordInput` | A create or update body (`Body`) and the form session key (`SessionKey`) whose file bindings the save applies. |
| `cabana.FileItem` | One file of a fileupload field: id, name, size, content type, title, description, `sort_order`, `pending`, and `url`/`thumb_url` for public relations. | | `cabana.FileItem` | One file of a fileupload field: id, name, size, content type, title, description, `sort_order`, `pending`, and `url`/`thumb_url` for public relations. |
| `cabana.ThumbOptions` | A fileupload field's `thumbOptions` (the preview thumbnail `mode`). | | `cabana.ThumbOptions` | A fileupload field's `thumbOptions` (the preview thumbnail `mode`). |
| `cabana.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`). | | `cabana.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). |
## Configuration ## Configuration

View File

@@ -662,3 +662,118 @@ func AdminFileList() {}
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field} [post] // @Router /{vendor}/{plugin}/{controller}/{id}/files/{field} [post]
func AdminFileUpload() {} 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() {}

View File

@@ -13,6 +13,8 @@ import (
"git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gocloud.dev/blob"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -20,6 +22,11 @@ import (
// CRUDService runs schema-projected record and bulk operations. // CRUDService runs schema-projected record and bulk operations.
type CRUDService struct { type CRUDService struct {
DB *gorm.DB 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 // RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is

View File

@@ -2,6 +2,7 @@ package cabana
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
@@ -11,6 +12,7 @@ import (
"git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// SessionKeyHeader carries the admin SPA's form session key (D-02): a // SessionKeyHeader carries the admin SPA's form session key (D-02): a
@@ -37,48 +39,59 @@ func sessionKeyFrom(r *http.Request) (string, bool, error) {
} }
// commitDeferred applies the file bindings of in.SessionKey to the saved // commitDeferred applies the file bindings of in.SessionKey to the saved
// target inside the save transaction (D-04). It reads every binding of the // target inside the save transaction (D-04), then rechecks the file limits.
// 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 // It reads every binding of the key, the authenticated admin and the
// saves with one key serialize; binds attach their pending file, and the // controller's morph type whose master_field is a fileupload field allowed
// applied rows are deleted. Bindings of other fields stay for the purge. // 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 { 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 return nil
} }
principal, _ := bouncer.User(ctx) var fields []*compiledFile
if principal == nil || !principal.Backend || principal.ID == 0 {
return nil
}
fields := make([]string, 0, len(cc.files))
for _, field := range cc.Form.Fields { for _, field := range cc.Form.Fields {
if cf := cc.files[field.Name]; cf != nil && contextAllows(cc, cf.name, op) { 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 return nil
} }
morph, err := lagoon.MorphType(tx, target) morph, err := lagoon.MorphType(tx, target)
if err != nil { if err != nil {
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
} }
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
}
key := lagoon.DeferredKey{SessionKey: in.SessionKey, AdminID: principal.ID, MasterType: morph} key := lagoon.DeferredKey{SessionKey: in.SessionKey, AdminID: principal.ID, MasterType: morph}
rows, err := lagoon.DeferredBindings(ctx, tx, key, fields) rows, err := lagoon.DeferredBindings(ctx, tx, key, names)
if err != nil { if err != nil {
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
} }
ownerID := primaryText(target)
if ownerID == "" {
return nil
}
applied := make([]uint, 0, len(rows)) applied := make([]uint, 0, len(rows))
for _, row := range rows { for _, row := range rows {
cf := cc.files[row.MasterField] cf := cc.files[row.MasterField]
if cf == nil || row.SlaveType != lagoon.DeferredFileType || !row.IsBind { if cf == nil || row.SlaveType != lagoon.DeferredFileType {
continue continue
} }
if err := s.applyFileBind(ctx, tx, cf, morph, ownerID, row); err != nil { 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) return lifecycleFailure(cc, err)
} }
applied = append(applied, row.ID) applied = append(applied, row.ID)
@@ -86,11 +99,35 @@ func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *Compil
if err := lagoon.DeferredForget(ctx, tx, applied); err != nil { if err := lagoon.DeferredForget(ctx, tx, applied); err != nil {
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
} }
}
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 return nil
} }
// applyFileBind attaches a pending upload to the owner. A row that is gone // applyFileBind attaches a pending upload to the owner. A row that is gone
// or already attached somewhere is ignored. // 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 { 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) id, err := strconv.ParseUint(row.SlaveID, 10, 64)
if err != nil || id == 0 { 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 != "" { if err != nil || f == nil || f.AttachmentID != "" || f.AttachmentType != "" {
return err 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). Where("id = ?", f.ID).
Updates(map[string]any{"attachment_type": morph, "attachment_id": ownerID, "field": cf.name}).Error 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 // primaryText is the saved record's primary key as system_files stores it
// in attachment_id (Winter keeps the morph key as a string). // in attachment_id (Winter keeps the morph key as a string).
func primaryText(model any) string { func primaryText(model any) string {

View File

@@ -2,6 +2,7 @@ package cabana
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -22,6 +23,7 @@ import (
"git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/goccy/go-yaml/ast" "github.com/goccy/go-yaml/ast"
"gocloud.dev/blob" "gocloud.dev/blob"
"gocloud.dev/gcerrors"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "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() ctx, tr := r.Context(), s.translator()
var detail string var detail string
switch { switch {
case cf == nil:
case errors.Is(err, attach.ErrTooLarge): case errors.Is(err, attach.ErrTooLarge):
kb := strconv.FormatInt(cf.maxBytes/1024, 10) kb := strconv.FormatInt(cf.maxBytes/1024, 10)
detail = fileMessage(ctx, tr, "max.file", cf.name, map[string]string{"max": kb}) 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) { func logFileFailure(r *http.Request, err error) {
var ve *ValidationError var ve *ValidationError
var missing recordNotFound var missing recordNotFound
var forbidden fileForbidden if errors.As(err, &ve) || errors.As(err, &missing) {
if errors.As(err, &ve) || errors.As(err, &missing) || errors.As(err, &forbidden) {
return return
} }
controller := r.PathValue("vendor") + "." + r.PathValue("plugin") + "." + r.PathValue("controller") 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) 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 // 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. // EOF, so a failed upload tells a malformed body from a storage failure.
type bodyReader struct { type bodyReader struct {
@@ -860,3 +856,419 @@ func lockFile(ctx context.Context, tx *gorm.DB, id uint) (*attach.File, error) {
} }
return &f, nil 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()
}

View File

@@ -1,7 +1,9 @@
package cabana_test package cabana_test
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -9,6 +11,8 @@ import (
"git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon" "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 { 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) 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(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
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)
}
}

View File

@@ -279,6 +279,16 @@ func (s *service) mount(r pact.Router) {
// record being created in the X-Session-Key session. // record being created in the X-Session-Key session.
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload)) g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
constrainFile(g) 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 // The SPA shell: public, no guard. ServeMux prefers every API pattern
// above over the {path...} wildcard. // above over the {path...} wildcard.
@@ -309,6 +319,11 @@ func constrainFile(g pact.Router) {
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*") 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) { func constrainNested(g pact.Router) {
constrainController(g) constrainController(g)
g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*") g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*")
@@ -787,7 +802,7 @@ func (s *service) crud() (CRUDService, error) {
if err != nil { if err != nil {
return CRUDService{}, err 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) { func (s *service) list(w http.ResponseWriter, r *http.Request) {

View File

@@ -45,6 +45,22 @@ type conformCase struct {
ref string ref string
call func(t *testing.T, env *conformEnv) *httptest.ResponseRecorder call func(t *testing.T, env *conformEnv) *httptest.ResponseRecorder
decode func(dec *json.Decoder) error 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 { func into[T any]() func(*json.Decoder) error {
@@ -66,33 +82,33 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
cases := []conformCase{ cases := []conformCase{
{"POST /auth/login", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { {"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) 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 { {"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) rec := e.send(t, http.MethodPost, "/auth/refresh", nil, true)
e.token = accessToken(t, rec.Body.Bytes()) e.token = accessToken(t, rec.Body.Bytes())
return rec 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 { {"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) 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 { {"GET /lang", 200, "cabana.Envelope-cabana_LangBundle", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/lang", nil, false) 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 { {"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) 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 { {"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) 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 { {"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) 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 { {"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) 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 { {"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) 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 { {"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) rec := e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true)
var body cabana.Envelope[cabana.ListSchema] 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.Scripts[0], "text/javascript; charset=utf-8")
e.assertPluginAsset(t, body.Data.Assets.Styles[0], "text/css; charset=utf-8") e.assertPluginAsset(t, body.Data.Assets.Styles[0], "text/css; charset=utf-8")
return rec 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 { {"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) 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 { {"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) 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 { {"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) 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 { {"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) 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 { {"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) rec := e.upload(t, 0, "photos", "photo.png", conformPNG(t), e.sessionKey)
}, into[cabana.Envelope[cabana.FileItem]]()}, 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 { {"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}) 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] 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) t.Fatalf("pending file list = %s (%v)", rec.Body.String(), err)
} }
return rec 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 { {"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) 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()) e.gadgetID = dataID(t, rec.Body.Bytes())
return rec 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 { {"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) 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 // 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) t.Fatalf("widget fill = %#v, want only name", body.Data.Fill)
} }
return rec 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 { {"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) 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+`"`) { 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()) t.Fatalf("header partial status=%d body=%s", header.Code, header.Body.String())
} }
return rec 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 { {"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) 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 { {"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) 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 { {"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) 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 { {"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) 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 { {"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) 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 { {"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) 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 { {"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) 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 { {"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) 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 { {"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) 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) 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 { {"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) 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 { {"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) 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{} inventory := map[string]bool{}
@@ -223,12 +263,19 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
if rec.Code != tc.status { if rec.Code != tc.status {
t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String()) 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 := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
if err := tc.decode(dec); err != nil { if err := tc.decode(dec); err != nil {
t.Fatalf("body does not match its documented type: %v\n%s", err, rec.Body.String()) 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)) got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status))
if got != tc.ref { if got != tc.ref {
t.Fatalf("admin.json documents %q for %s %d, the handler writes %s", got, tc.key, tc.status, tc.ref) t.Fatalf("admin.json documents %q for %s %d, the handler writes %s", got, tc.key, tc.status, tc.ref)
@@ -246,12 +293,19 @@ type conformSpecDoc struct {
Content map[string]struct { Content map[string]struct {
Schema struct { Schema struct {
Ref string `json:"$ref"` Ref string `json:"$ref"`
Type string `json:"type"`
Format string `json:"format"`
} `json:"schema"` } `json:"schema"`
} `json:"content"` } `json:"content"`
} `json:"responses"` } `json:"responses"`
} `json:"paths"` } `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 { func (d conformSpecDoc) ref(path, method, status string) string {
ref := d.Paths[path][method].Responses[status].Content["application/json"].Schema.Ref ref := d.Paths[path][method].Responses[status].Content["application/json"].Schema.Ref
return strings.TrimPrefix(ref, "#/components/schemas/") return strings.TrimPrefix(ref, "#/components/schemas/")
@@ -282,6 +336,8 @@ type conformEnv struct {
token string token string
stamp string stamp string
sessionKey string sessionKey string
photoID uint
manualID uint
groupID uint groupID uint
memberID uint memberID uint
gadgetID uint gadgetID uint
@@ -491,7 +547,7 @@ type conformGadget struct {
func (conformGadget) TableName() string { return "cabana_conform_gadgets" } func (conformGadget) TableName() string { return "cabana_conform_gadgets" }
func (conformGadget) MorphName() string { return "acme.conform.gadget" } func (conformGadget) MorphName() string { return "acme.conform.gadget" }
func (conformGadget) AttachRelations() []attach.Relation { 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) Fillable() []string { return []string{"name", "active"} }
func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} } func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
@@ -733,6 +789,12 @@ update:
maxFilesize: 0.5 maxFilesize: 0.5
thumbOptions: thumbOptions:
mode: crop mode: crop
manual:
label: Manual
type: fileupload
fileTypes: [pdf, png, svg, txt]
useCaption: true
context: update
`), `),
"models/settings/fields.yaml": file(`fields: "models/settings/fields.yaml": file(`fields:
enabled: enabled:

View File

@@ -89,6 +89,7 @@ func TestPhase10Coverage(t *testing.T) {
sort.Strings(unsafe) sort.Strings(unsafe)
want := []string{ want := []string{
"DELETE /{vendor}/{plugin}/{controller}/{id}", "DELETE /{vendor}/{plugin}/{controller}/{id}",
"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
"POST /auth/login", "POST /auth/login",
"POST /auth/logout", "POST /auth/logout",
"POST /auth/refresh", "POST /auth/refresh",
@@ -97,13 +98,15 @@ func TestPhase10Coverage(t *testing.T) {
"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", "POST /{vendor}/{plugin}/{controller}/toolbar/{action}",
"POST /{vendor}/{plugin}/{controller}/widgets/{field}", "POST /{vendor}/{plugin}/{controller}/widgets/{field}",
"POST /{vendor}/{plugin}/{controller}/{id}/files/{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}/link",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
"PUT /settings/{code}", "PUT /settings/{code}",
"PUT /{vendor}/{plugin}/{controller}/{id}", "PUT /{vendor}/{plugin}/{controller}/{id}",
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
} }
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") { if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 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 // The routes added in Phase 10 are safe reads: GET /lang and the shared
// nested pattern serving field options, filter options and relation lists. // nested pattern serving field options, filter options and relation lists.

View File

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

View File

@@ -64,6 +64,11 @@ var phase09Routes = []adminRoute{
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", mounted: nestedGetRoute}, {key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", mounted: nestedGetRoute},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}"},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"},
{key: "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 /assets/{vendor}/{plugin}/{file...}", public: true, spa: true},
{key: "GET ", public: true, spa: true}, {key: "GET ", public: true, spa: true},
{key: "GET /{path...}", public: true, spa: true}, {key: "GET /{path...}", public: true, spa: true},
@@ -290,6 +295,11 @@ func phase09ProtectedCalls() []phase09Call {
{"relation-unlink", (*service).relationUnlink}, {"relation-unlink", (*service).relationUnlink},
{"file-list", (*service).fileList}, {"file-list", (*service).fileList},
{"file-upload", (*service).fileUpload}, {"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-schema", (*service).settingsSchema},
{"settings-get", (*service).settingsGet}, {"settings-get", (*service).settingsGet},
{"settings-put", (*service).settingsPut}, {"settings-put", (*service).settingsPut},