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"
},
"cabana.AdminFileCaptionRequest": {
"properties": {
"description": {
"type": "string"
},
"title": {
"type": "string"
}
},
"type": "object"
},
"cabana.AdminIDsRequest": {
"properties": {
"ids": {
@@ -353,6 +364,21 @@
],
"type": "object"
},
"cabana.Envelope-cabana_FileMutationResult": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.FileMutationResult"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_FormView": {
"properties": {
"data": {
@@ -538,6 +564,17 @@
],
"type": "object"
},
"cabana.FileMutationResult": {
"properties": {
"removed": {
"type": "integer"
}
},
"required": [
"removed"
],
"type": "object"
},
"cabana.FilterOption": {
"properties": {
"label": {
@@ -3923,6 +3960,690 @@
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder": {
"post": {
"description": "ids must be exactly the field's visible files (attached minus pending removals plus pending uploads); they receive the existing sort_order values in the submitted order, at once. attachMany only, otherwise 403.",
"parameters": [
{
"description": "Vendor",
"in": "path",
"name": "vendor",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Plugin",
"in": "path",
"name": "plugin",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Controller",
"in": "path",
"name": "controller",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Owner id (0 for the record being created)",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "fileupload field name",
"in": "path",
"name": "field",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Form session key; needed for pending uploads",
"in": "header",
"name": "X-Session-Key",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminIDsRequest"
}
}
},
"description": "File ids in the new order",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-array_cabana_FileItem"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Not Found"
},
"413": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Request Entity Too Large"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Reorder the files of a field",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}": {
"delete": {
"description": "Removing an attached file is deferred to the record's next save with the same X-Session-Key; removing a pending upload deletes it at once.",
"parameters": [
{
"description": "Vendor",
"in": "path",
"name": "vendor",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Plugin",
"in": "path",
"name": "plugin",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Controller",
"in": "path",
"name": "controller",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Owner id (0 for the record being created)",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "fileupload field name",
"in": "path",
"name": "field",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "File id",
"in": "path",
"name": "file",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "Form session key",
"in": "header",
"name": "X-Session-Key",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_FileMutationResult"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Remove a file",
"tags": [
"admin"
]
},
"put": {
"description": "Saves at once (not deferred). The field must declare useCaption, otherwise 403. Omitted keys are left unchanged; unknown keys are refused.",
"parameters": [
{
"description": "Vendor",
"in": "path",
"name": "vendor",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Plugin",
"in": "path",
"name": "plugin",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Controller",
"in": "path",
"name": "controller",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Owner id (0 for the record being created)",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "fileupload field name",
"in": "path",
"name": "field",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "File id",
"in": "path",
"name": "file",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "Form session key; needed for a pending upload",
"in": "header",
"name": "X-Session-Key",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminFileCaptionRequest"
}
}
},
"description": "Title and description",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_FileItem"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Not Found"
},
"413": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Request Entity Too Large"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Save a file's title and description",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download": {
"get": {
"description": "Streams a file of a protected (Public false) relation that belongs to a record the admin may load, or is pending in the admin's own session. Public files are 404. JPEG, PNG, GIF and WebP are served inline with their type; everything else as an application/octet-stream attachment. Responses carry X-Content-Type-Options nosniff, Cache-Control private, no-store and a sandboxing Content-Security-Policy.",
"parameters": [
{
"description": "Vendor",
"in": "path",
"name": "vendor",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Plugin",
"in": "path",
"name": "plugin",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Controller",
"in": "path",
"name": "controller",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Owner id (0 for the record being created)",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "fileupload field name",
"in": "path",
"name": "field",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "File id",
"in": "path",
"name": "file",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "Form session key; needed for a pending upload",
"in": "header",
"name": "X-Session-Key",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Download a protected file",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb": {
"get": {
"description": "The preview thumbnail (imageWidth by imageHeight, 240 by 240 by default, in thumbOptions.mode) of a protected image file, scoped like the download route. A file that is not a JPEG, PNG, GIF or WebP image is 404.",
"parameters": [
{
"description": "Vendor",
"in": "path",
"name": "vendor",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Plugin",
"in": "path",
"name": "plugin",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Controller",
"in": "path",
"name": "controller",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Owner id (0 for the record being created)",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "fileupload field name",
"in": "path",
"name": "field",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "File id",
"in": "path",
"name": "file",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "Form session key; needed for a pending upload",
"in": "header",
"name": "X-Session-Key",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Thumbnail of a protected image",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}": {
"get": {
"parameters": [

View File

@@ -1887,6 +1887,471 @@ export interface paths {
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Reorder the files of a field
* @description ids must be exactly the field's visible files (attached minus pending removals plus pending uploads); they receive the existing sort_order values in the submitted order, at once. attachMany only, otherwise 403.
*/
post: {
parameters: {
query?: never;
header?: {
/** @description Form session key; needed for pending uploads */
"X-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description fileupload field name */
field: string;
};
cookie?: never;
};
/** @description File ids in the new order */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-array_cabana_FileItem"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Save a file's title and description
* @description Saves at once (not deferred). The field must declare useCaption, otherwise 403. Omitted keys are left unchanged; unknown keys are refused.
*/
put: {
parameters: {
query?: never;
header?: {
/** @description Form session key; needed for a pending upload */
"X-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description fileupload field name */
field: string;
/** @description File id */
file: number;
};
cookie?: never;
};
/** @description Title and description */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminFileCaptionRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_FileItem"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
post?: never;
/**
* Remove a file
* @description Removing an attached file is deferred to the record's next save with the same X-Session-Key; removing a pending upload deletes it at once.
*/
delete: {
parameters: {
query?: never;
header: {
/** @description Form session key */
"X-Session-Key": string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description fileupload field name */
field: string;
/** @description File id */
file: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_FileMutationResult"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Download a protected file
* @description Streams a file of a protected (Public false) relation that belongs to a record the admin may load, or is pending in the admin's own session. Public files are 404. JPEG, PNG, GIF and WebP are served inline with their type; everything else as an application/octet-stream attachment. Responses carry X-Content-Type-Options nosniff, Cache-Control private, no-store and a sandboxing Content-Security-Policy.
*/
get: {
parameters: {
query?: never;
header?: {
/** @description Form session key; needed for a pending upload */
"X-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description fileupload field name */
field: string;
/** @description File id */
file: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Thumbnail of a protected image
* @description The preview thumbnail (imageWidth by imageHeight, 240 by 240 by default, in thumbOptions.mode) of a protected image file, scoped like the download route. A file that is not a JPEG, PNG, GIF or WebP image is 404.
*/
get: {
parameters: {
query?: never;
header?: {
/** @description Form session key; needed for a pending upload */
"X-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description fileupload field name */
field: string;
/** @description File id */
file: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}": {
parameters: {
query?: never;
@@ -2267,6 +2732,10 @@ export interface components {
};
message: string;
};
"cabana.AdminFileCaptionRequest": {
description?: string;
title?: string;
};
"cabana.AdminIDsRequest": {
ids: number[];
};
@@ -2351,6 +2820,10 @@ export interface components {
data: components["schemas"]["cabana.FileItem"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_FileMutationResult": {
data: components["schemas"]["cabana.FileMutationResult"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_FormView": {
data: components["schemas"]["cabana.FormView"];
meta: components["schemas"]["cabana.SuccessMeta"];
@@ -2402,6 +2875,9 @@ export interface components {
title: string;
url?: string;
};
"cabana.FileMutationResult": {
removed: number;
};
"cabana.FilterOption": {
label: string;
value: string;

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.
Removing a file is deferred the same way: the file disappears from the form at once and is deleted by the save (a pending upload that is removed is deleted at once). On an attachOne relation, saving a new upload deletes the file it replaces. Captions (with `useCaption`) and the order of an attachMany field are saved at once, as in WinterCMS. After applying the session's work, the save checks `maxFiles` and, for a `required: true` fileupload field, that at least one file is attached; a failure is a 422 on the field and keeps the pending work. Files of a protected relation are shown through authenticated admin routes only; see [Protected files in the admin](../database/attachments.md#protected-files-in-the-admin).
The limits are enforced on the server: the upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB (413 `payload_too_large` past it), and a file that is too large, of a type the field does not allow, or not a valid image in image mode is a 422 on the field.
## What a save may write

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.
### Protected files in the admin
A `type: fileupload` field on a protected relation never shows a public URL in the admin; see [Forms](../backend/forms.md#file-uploads). The admin SPA reads such a file through two authenticated admin API routes under the `backend` guard:
- GET `{prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download` streams the original.
- GET `{prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb` streams the preview thumbnail, built with `attach.File.ThumbKey`; a file that is not a JPEG, PNG, GIF or WebP image has none.
Both routes look the file up with one query scoped to its record, which the controller's `pact.FormExtendQuery` must let the administrator load, or to an upload pending in the administrator's own form session (`X-Session-Key`). A file of another record, a public file and a file outside that scope all answer 404. Every response carries `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and `Content-Security-Policy: default-src 'none'; sandbox`. Only JPEG, PNG, GIF and WebP are served inline with their own type; any other file, an SVG included, is sent as an `application/octet-stream` attachment, so a stored file never runs script in the admin's origin.
## Deleting files after commit
A rolled-back transaction can restore a row but not the bytes of a deleted blob. Deleting an owner's files is therefore split in two:

View File

@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
)
func main() {
@@ -322,8 +323,17 @@ func convertResponses(res map[string]any, produces []string) map[string]any {
converted[k] = v
}
if schema != nil {
// A Swagger 2.0 file response is binary under the operation's
// media types; any other schema (an error envelope next to a
// binary success) is JSON.
types := produces
if m, ok := schema.(map[string]any); ok && m["type"] == "file" {
schema = map[string]any{"type": "string", "format": "binary"}
} else {
types = jsonTypes(produces)
}
content := map[string]any{}
for _, ct := range produces {
for _, ct := range types {
content[ct] = map[string]any{"schema": schema}
}
converted["content"] = content
@@ -333,6 +343,21 @@ func convertResponses(res map[string]any, produces []string) map[string]any {
return out
}
// jsonTypes keeps the JSON media types of produces, or application/json
// when there are none.
func jsonTypes(produces []string) []string {
var out []string
for _, ct := range produces {
if strings.Contains(ct, "json") {
out = append(out, ct)
}
}
if len(out) == 0 {
return []string{"application/json"}
}
return out
}
func convertSecurity(sec map[string]any) map[string]any {
out := make(map[string]any, len(sec))
for name, raw := range sec {

View File

@@ -234,3 +234,18 @@ func TestConvertFormData(t *testing.T) {
t.Fatalf("form schema = %#v", schema)
}
}
func TestConvertFileResponse(t *testing.T) {
res := convertResponses(decode(t, `{
"200": {"description": "OK", "schema": {"type": "file"}},
"404": {"description": "Not Found", "schema": {"$ref": "#/definitions/acme.Error"}}
}`), []string{"application/octet-stream"})
ok := res["200"].(map[string]any)["content"].(map[string]any)
if !reflect.DeepEqual(ok, map[string]any{"application/octet-stream": map[string]any{"schema": map[string]any{"type": "string", "format": "binary"}}}) {
t.Fatalf("file response = %#v", ok)
}
missing := res["404"].(map[string]any)["content"].(map[string]any)
if _, ok := missing["application/json"]; !ok || len(missing) != 1 {
t.Fatalf("error response next to a binary success = %#v", missing)
}
}

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. |
| GET `.../{id}/files/{field}` | The files of a `type: fileupload` field: attached files minus the session's pending removals, plus its pending uploads, in `sort_order`. `{id}` 0 is the record being created and needs `X-Session-Key`. |
| POST `.../{id}/files/{field}` | Upload one multipart `file_data` part; it is bound to the `X-Session-Key` session (required) and attached by the record's next save. Answers 201 with the pending `cabana.FileItem`. |
| PUT `.../{id}/files/{field}/{file}` | Save a file's `title` and `description` at once; the field must declare `useCaption` (403 otherwise). |
| DELETE `.../{id}/files/{field}/{file}` | Remove a file: an attached file is removed by the next save with the same `X-Session-Key` (required), a pending upload is deleted at once. |
| POST `.../{id}/files/{field}/reorder` | `{ids}` in the new order, exactly the field's visible files; they take the existing `sort_order` values at once. attachMany only (403 otherwise). |
| GET `.../{id}/files/{field}/{file}/download`, GET `.../{id}/files/{field}/{file}/thumb` | Stream a protected file or its preview thumbnail; see the protected files note below. |
Every file route resolves its file with one query scoped to the record (loaded through `pact.FormExtendQuery`) or to the administrator's own pending uploads: a file of another record is `not_found`, never `forbidden`. The save applies the session's file work in its transaction: a pending upload on an attachOne field replaces the file attached before, a deferred removal deletes the attached file, and the blobs of deleted files are removed after commit. After that the save checks `maxFiles` and a `required` fileupload field (at least one file) and answers 422 on the field when either fails; the pending work stays for the next attempt.
Protected files (a relation with `Public` false) never get a public URL. The download and thumb routes serve only `is_public` false files, with `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and `Content-Security-Policy: default-src 'none'; sandbox`; JPEG, PNG, GIF and WebP are served inline with their type, every other type as an `application/octet-stream` attachment. The thumb route answers 404 for a file that is not one of those images.
Every path under the prefix that no API route matches is served by the admin SPA; unmatched API paths return the `not_found` error envelope instead.
@@ -167,7 +175,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.RecordInput` | A create or update body (`Body`) and the form session key (`SessionKey`) whose file bindings the save applies. |
| `cabana.FileItem` | One file of a fileupload field: id, name, size, content type, title, description, `sort_order`, `pending`, and `url`/`thumb_url` for public relations. |
| `cabana.ThumbOptions` | A fileupload field's `thumbOptions` (the preview thumbnail `mode`). |
| `cabana.AdminFileList` / `cabana.AdminFileUpload` | Swag annotations of the file list and upload routes. |
| `cabana.FileMutationResult` | Answer of the file removal route: `removed`. |
| `cabana.AdminFileCaptionRequest` | Body of the file caption route: optional `title` and `description`. Unknown keys are refused. |
| `cabana.AdminFileList` / `cabana.AdminFileUpload` / `cabana.AdminFileUpdate` / `cabana.AdminFileRemove` / `cabana.AdminFileReorder` / `cabana.AdminFileDownload` / `cabana.AdminFileThumb` | Swag annotations of the file routes. |
| `cabana.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). |
## Configuration

View File

@@ -662,3 +662,118 @@ func AdminFileList() {}
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field} [post]
func AdminFileUpload() {}
// AdminFileUpdate documents the caption route of a fileupload field.
//
// @Summary Save a file's title and description
// @Description Saves at once (not deferred). The field must declare useCaption, otherwise 403. Omitted keys are left unchanged; unknown keys are refused.
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param field path string true "fileupload field name"
// @Param file path integer true "File id"
// @Param X-Session-Key header string false "Form session key; needed for a pending upload"
// @Param body body AdminFileCaptionRequest true "Title and description"
// @Success 200 {object} Envelope[FileItem]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file} [put]
func AdminFileUpdate() {}
// AdminFileRemove documents the removal of a file from a fileupload field.
//
// @Summary Remove a file
// @Description Removing an attached file is deferred to the record's next save with the same X-Session-Key; removing a pending upload deletes it at once.
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param field path string true "fileupload field name"
// @Param file path integer true "File id"
// @Param X-Session-Key header string true "Form session key"
// @Success 200 {object} Envelope[FileMutationResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file} [delete]
func AdminFileRemove() {}
// AdminFileReorder documents the reorder route of an attachMany field.
//
// @Summary Reorder the files of a field
// @Description ids must be exactly the field's visible files (attached minus pending removals plus pending uploads); they receive the existing sort_order values in the submitted order, at once. attachMany only, otherwise 403.
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param field path string true "fileupload field name"
// @Param X-Session-Key header string false "Form session key; needed for pending uploads"
// @Param body body AdminIDsRequest true "File ids in the new order"
// @Success 200 {object} Envelope[[]FileItem]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder [post]
func AdminFileReorder() {}
// AdminFileDownload documents the download of a protected file.
//
// @Summary Download a protected file
// @Description Streams a file of a protected (Public false) relation that belongs to a record the admin may load, or is pending in the admin's own session. Public files are 404. JPEG, PNG, GIF and WebP are served inline with their type; everything else as an application/octet-stream attachment. Responses carry X-Content-Type-Options nosniff, Cache-Control private, no-store and a sandboxing Content-Security-Policy.
// @Tags admin
// @Produce octet-stream
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param field path string true "fileupload field name"
// @Param file path integer true "File id"
// @Param X-Session-Key header string false "Form session key; needed for a pending upload"
// @Success 200 {file} file
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download [get]
func AdminFileDownload() {}
// AdminFileThumb documents the thumbnail of a protected image.
//
// @Summary Thumbnail of a protected image
// @Description The preview thumbnail (imageWidth by imageHeight, 240 by 240 by default, in thumbOptions.mode) of a protected image file, scoped like the download route. A file that is not a JPEG, PNG, GIF or WebP image is 404.
// @Tags admin
// @Produce octet-stream
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id (0 for the record being created)"
// @Param field path string true "fileupload field name"
// @Param file path integer true "File id"
// @Param X-Session-Key header string false "Form session key; needed for a pending upload"
// @Success 200 {file} file
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb [get]
func AdminFileThumb() {}

View File

@@ -13,6 +13,8 @@ import (
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gocloud.dev/blob"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -20,6 +22,11 @@ import (
// CRUDService runs schema-projected record and bulk operations.
type CRUDService struct {
DB *gorm.DB
// bucket deletes the blobs of files a save replaces or removes, after
// commit; tr localizes the file limit messages. Both may be nil.
bucket *blob.Bucket
tr *phrasebook.Translator
}
// RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is

View File

@@ -2,6 +2,7 @@ package cabana
import (
"context"
"errors"
"net/http"
"regexp"
"strconv"
@@ -11,6 +12,7 @@ import (
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SessionKeyHeader carries the admin SPA's form session key (D-02): a
@@ -37,60 +39,95 @@ func sessionKeyFrom(r *http.Request) (string, bool, error) {
}
// commitDeferred applies the file bindings of in.SessionKey to the saved
// target inside the save transaction (D-04). It reads every binding of the
// key, the authenticated admin and the controller's morph type whose
// master_field is a fileupload field allowed in op, locked FOR UPDATE so two
// saves with one key serialize; binds attach their pending file, and the
// applied rows are deleted. Bindings of other fields stay for the purge.
// target inside the save transaction (D-04), then rechecks the file limits.
//
// It reads every binding of the key, the authenticated admin and the
// controller's morph type whose master_field is a fileupload field allowed
// in op, locked FOR UPDATE so two saves with one key serialize, and applies
// them in id order: a bind attaches its pending upload (on an attachOne
// field after deleting the file it replaces), an unbind deletes the attached
// file. Blobs of deleted files are removed after commit, and the applied
// rows are deleted. Bindings of other fields stay for the purge. Then every
// fileupload field allowed in op must hold at most maxFiles files and, when
// required, at least one; otherwise the save fails with a 422 on the field,
// the transaction rolls back and the bindings stay in place.
func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, op string, in RecordInput) error {
if cc == nil || len(cc.files) == 0 || in.SessionKey == "" {
if cc == nil || cc.Form == nil || len(cc.files) == 0 {
return nil
}
principal, _ := bouncer.User(ctx)
if principal == nil || !principal.Backend || principal.ID == 0 {
return nil
}
fields := make([]string, 0, len(cc.files))
var fields []*compiledFile
for _, field := range cc.Form.Fields {
if cf := cc.files[field.Name]; cf != nil && contextAllows(cc, cf.name, op) {
fields = append(fields, cf.name)
fields = append(fields, cf)
}
}
if len(fields) == 0 {
ownerID := primaryText(target)
if len(fields) == 0 || ownerID == "" {
return nil
}
morph, err := lagoon.MorphType(tx, target)
if err != nil {
return lifecycleFailure(cc, err)
}
key := lagoon.DeferredKey{SessionKey: in.SessionKey, AdminID: principal.ID, MasterType: morph}
rows, err := lagoon.DeferredBindings(ctx, tx, key, fields)
if err != nil {
return lifecycleFailure(cc, err)
}
ownerID := primaryText(target)
if ownerID == "" {
return nil
}
applied := make([]uint, 0, len(rows))
for _, row := range rows {
cf := cc.files[row.MasterField]
if cf == nil || row.SlaveType != lagoon.DeferredFileType || !row.IsBind {
continue
principal, _ := bouncer.User(ctx)
if in.SessionKey != "" && principal != nil && principal.Backend && principal.ID != 0 {
names := make([]string, len(fields))
for i, cf := range fields {
names[i] = cf.name
}
if err := s.applyFileBind(ctx, tx, cf, morph, ownerID, row); err != nil {
key := lagoon.DeferredKey{SessionKey: in.SessionKey, AdminID: principal.ID, MasterType: morph}
rows, err := lagoon.DeferredBindings(ctx, tx, key, names)
if err != nil {
return lifecycleFailure(cc, err)
}
applied := make([]uint, 0, len(rows))
for _, row := range rows {
cf := cc.files[row.MasterField]
if cf == nil || row.SlaveType != lagoon.DeferredFileType {
continue
}
if row.IsBind {
err = s.applyFileBind(ctx, tx, cf, morph, ownerID, row)
} else {
err = s.applyFileUnbind(ctx, tx, cf, morph, ownerID, row)
}
if err != nil {
return lifecycleFailure(cc, err)
}
applied = append(applied, row.ID)
}
if err := lagoon.DeferredForget(ctx, tx, applied); err != nil {
return lifecycleFailure(cc, err)
}
applied = append(applied, row.ID)
}
if err := lagoon.DeferredForget(ctx, tx, applied); err != nil {
return lifecycleFailure(cc, err)
details := map[string]any{}
for _, cf := range fields {
if !cf.required && (!cf.relation.Many || cf.maxFiles == 0) {
continue
}
var n int64
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).
Where("attachment_type = ? AND attachment_id = ? AND field = ?", morph, ownerID, cf.name).
Count(&n).Error
if err != nil {
return lifecycleFailure(cc, err)
}
switch {
case cf.required && n == 0:
details[cf.name] = []string{fileMessage(ctx, s.tr, "required", cf.name, nil)}
case cf.relation.Many && cf.maxFiles > 0 && n > int64(cf.maxFiles):
details[cf.name] = []string{fileMessage(ctx, s.tr, "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)})}
}
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}
// applyFileBind attaches a pending upload to the owner. A row that is gone
// or already attached somewhere is ignored.
// or already attached somewhere is ignored. On an attachOne field the file
// it replaces is deleted first (WinterCMS's AttachOne::add).
func (s CRUDService) applyFileBind(ctx context.Context, tx *gorm.DB, cf *compiledFile, morph, ownerID string, row lagoon.DeferredBinding) error {
id, err := strconv.ParseUint(row.SlaveID, 10, 64)
if err != nil || id == 0 {
@@ -100,11 +137,56 @@ func (s CRUDService) applyFileBind(ctx context.Context, tx *gorm.DB, cf *compile
if err != nil || f == nil || f.AttachmentID != "" || f.AttachmentType != "" {
return err
}
return tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
if !cf.relation.Many {
var previous []attach.File
err := q.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("attachment_type = ? AND attachment_id = ? AND field = ? AND id <> ?", morph, ownerID, cf.name, f.ID).
Find(&previous).Error
if err != nil {
return err
}
for _, old := range previous {
if err := s.deleteFile(ctx, tx, old); err != nil {
return err
}
}
}
return q.Model(&attach.File{}).
Where("id = ?", f.ID).
Updates(map[string]any{"attachment_type": morph, "attachment_id": ownerID, "field": cf.name}).Error
}
// applyFileUnbind deletes a file attached to this owner and field; a file
// that is not attached there is ignored.
func (s CRUDService) applyFileUnbind(ctx context.Context, tx *gorm.DB, cf *compiledFile, morph, ownerID string, row lagoon.DeferredBinding) error {
id, err := strconv.ParseUint(row.SlaveID, 10, 64)
if err != nil || id == 0 {
return nil
}
var f attach.File
err = tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND attachment_type = ? AND attachment_id = ? AND field = ?", id, morph, ownerID, cf.name).
Take(&f).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
return s.deleteFile(ctx, tx, f)
}
// deleteFile deletes a file row now and its blobs after commit.
func (s CRUDService) deleteFile(ctx context.Context, tx *gorm.DB, f attach.File) error {
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id = ?", f.ID).Delete(&attach.File{}).Error; err != nil {
return err
}
deleteBlobsAfterCommit(ctx, tx, s.bucket, f)
return nil
}
// primaryText is the saved record's primary key as system_files stores it
// in attachment_id (Winter keeps the morph key as a string).
func primaryText(model any) string {

View File

@@ -2,6 +2,7 @@ package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -22,6 +23,7 @@ import (
"git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/goccy/go-yaml/ast"
"gocloud.dev/blob"
"gocloud.dev/gcerrors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -734,6 +736,7 @@ func (s *service) writeFileError(w http.ResponseWriter, r *http.Request, cf *com
ctx, tr := r.Context(), s.translator()
var detail string
switch {
case cf == nil:
case errors.Is(err, attach.ErrTooLarge):
kb := strconv.FormatInt(cf.maxBytes/1024, 10)
detail = fileMessage(ctx, tr, "max.file", cf.name, map[string]string{"max": kb})
@@ -768,20 +771,13 @@ func (s *service) writeFileError(w http.ResponseWriter, r *http.Request, cf *com
func logFileFailure(r *http.Request, err error) {
var ve *ValidationError
var missing recordNotFound
var forbidden fileForbidden
if errors.As(err, &ve) || errors.As(err, &missing) || errors.As(err, &forbidden) {
if errors.As(err, &ve) || errors.As(err, &missing) {
return
}
controller := r.PathValue("vendor") + "." + r.PathValue("plugin") + "." + r.PathValue("controller")
slog.Default().ErrorContext(r.Context(), "cabana: file route failed", "controller", controller, "field", r.PathValue("field"), "error", err)
}
// fileForbidden is a file operation the field does not declare (a caption
// without useCaption, a reorder on attachOne): 403.
type fileForbidden struct{}
func (fileForbidden) Error() string { return "cabana: file operation not declared" }
// bodyReader remembers the first read error of the request body other than
// EOF, so a failed upload tells a malformed body from a storage failure.
type bodyReader struct {
@@ -860,3 +856,419 @@ func lockFile(ctx context.Context, tx *gorm.DB, id uint) (*attach.File, error) {
}
return &f, nil
}
// AdminFileCaptionRequest is the body of the file caption route. A nil
// field is left unchanged; unknown keys are refused.
type AdminFileCaptionRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
}
// pathFileID parses {file}; anything but a positive integer is not found.
func pathFileID(r *http.Request) (uint, error) {
n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("file")), 10, 64)
if err != nil || n == 0 {
return 0, recordNotFound{}
}
return uint(n), nil
}
// findFile loads one file of the scope with a single parent-scoped query:
// it must be attached to the scope's owner and field, or be a pending upload
// bound to the scope's session key. Anything else, a file of another record
// included, is recordNotFound (never 403).
func (sc *fileScope) findFile(ctx context.Context, tx *gorm.DB, id uint, lock bool) (*attach.File, error) {
fresh := func() *gorm.DB { return tx.Session(&gorm.Session{NewDB: true, Context: ctx}) }
var group *gorm.DB
if sc.ownerID > 0 {
group = fresh().Where("attachment_type = ? AND attachment_id = ? AND field = ?", sc.morph, sc.ownerText(), sc.file.name)
}
if sc.hasKey {
pending := "(attachment_id IS NULL OR attachment_id = '') AND CAST(id AS TEXT) IN (?)"
slaves := lagoon.DeferredSlaves(tx, sc.key, sc.file.name, lagoon.DeferredFileType, true)
if group == nil {
group = fresh().Where(pending, slaves)
} else {
group = group.Or(pending, slaves)
}
}
if group == nil {
return nil, recordNotFound{}
}
q := fresh().Where("id = ?", id).Where(group)
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
var f attach.File
err := q.Take(&f).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, recordNotFound{}
}
if err != nil {
return nil, err
}
return &f, nil
}
// withFileScope runs fn on the resolved scope of a file route inside one
// transaction and writes the error envelope on failure.
func (s *service) withFileScope(w http.ResponseWriter, r *http.Request, cc *CompiledController, fn func(ctx context.Context, tx *gorm.DB, sc *fileScope) error) bool {
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return false
}
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
sc, err := parentFileScope(ctx, tx, r, cc)
if err != nil {
return err
}
return fn(ctx, tx, sc)
})
if err != nil {
s.writeFileError(w, r, cc.files[r.PathValue("field")], nil, err)
return false
}
return true
}
// requireSessionKey answers 422 on session_key when the request has no
// valid X-Session-Key.
func requireSessionKey(w http.ResponseWriter, r *http.Request) bool {
_, ok, err := sessionKeyFrom(r)
if err == nil && !ok {
err = &ValidationError{Details: map[string]any{"session_key": []string{"The session key field is required."}}}
}
if err != nil {
writeCRUDError(w, err)
return false
}
return true
}
// deleteBlobsAfterCommit removes a deleted file's blob and thumbnails once
// the surrounding transaction has committed.
func deleteBlobsAfterCommit(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, f attach.File) {
keys := attach.BlobKeys(f)
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
if bucket == nil {
slog.Default().WarnContext(ctx, "cabana: no storage bucket; blobs of a deleted file were kept", "file_id", f.ID)
return
}
if err := attach.DeleteKeys(ctx, bucket, keys); err != nil {
slog.Default().WarnContext(ctx, "cabana: deleting a file's blobs failed", "file_id", f.ID, "error", err)
}
})
}
// fileRemove serves DELETE .../{id}/files/{field}/{file}: it defers the
// removal of an attached file to the next save, or cancels a pending upload
// at once (its row now, its blob after commit).
func (s *service) fileRemove(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if cc.files[r.PathValue("field")] == nil {
writeNotFound(w, r)
return
}
if !requireSessionKey(w, r) {
return
}
fileID, err := pathFileID(r)
if err != nil {
writeCRUDError(w, err)
return
}
bucket := s.bucket()
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
f, err := sc.findFile(ctx, tx, fileID, true)
if err != nil {
return err
}
cancelled, err := lagoon.DeferredUnbind(ctx, tx, sc.key, sc.file.name, lagoon.DeferredFileType, uitoa(f.ID))
if err != nil {
return err
}
if cancelled != nil && f.AttachmentID == "" {
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id = ?", f.ID).Delete(&attach.File{}).Error; err != nil {
return err
}
deleteBlobsAfterCommit(ctx, tx, bucket, *f)
}
return nil
})
if ok {
WriteData(w, http.StatusOK, FileMutationResult{Removed: 1}, nil)
}
})
}
// jsonCap is the body cap of the JSON file routes: http.body_limits.
// default_bytes, or 1 MiB when it is not configured.
func (s *service) jsonCap() int64 {
if s != nil && s.defaultBytes > 0 {
return s.defaultBytes
}
return 1 << 20
}
// decodeStrictBody decodes a capped JSON body into dest with unknown keys
// and trailing data refused.
func (s *service) decodeStrictBody(w http.ResponseWriter, r *http.Request, dest any) error {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap()))
dec.DisallowUnknownFields()
if err := dec.Decode(dest); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return err
}
return invalidBody()
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return invalidBody()
}
return nil
}
// fileUpdate serves PUT .../{id}/files/{field}/{file}: it saves a file's
// title and description at once (WinterCMS's onSaveAttachmentConfig). The
// field must declare useCaption.
func (s *service) fileUpdate(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cf := cc.files[r.PathValue("field")]
if cf == nil {
writeNotFound(w, r)
return
}
if !cf.field.UseCaption {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
fileID, err := pathFileID(r)
if err != nil {
writeCRUDError(w, err)
return
}
var in AdminFileCaptionRequest
if err := s.decodeStrictBody(w, r, &in); err != nil {
s.writeFileError(w, r, cf, nil, err)
return
}
bucket := s.bucket()
var item FileItem
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
f, err := sc.findFile(ctx, tx, fileID, true)
if err != nil {
return err
}
updates := map[string]any{}
if in.Title != nil {
updates["title"] = *in.Title
f.Title = in.Title
}
if in.Description != nil {
updates["description"] = *in.Description
f.Description = in.Description
}
if len(updates) > 0 {
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).Where("id = ?", f.ID).Updates(updates).Error; err != nil {
return err
}
}
item = fileItem(ctx, bucket, cf, f, f.AttachmentID == "")
return nil
})
if ok {
WriteData(w, http.StatusOK, item, nil)
}
})
}
// fileReorder serves POST .../{id}/files/{field}/reorder: the submitted ids
// must be exactly the field's visible files, and they receive the visible
// files' existing sort_order values, ascending, in the submitted order.
func (s *service) fileReorder(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cf := cc.files[r.PathValue("field")]
if cf == nil {
writeNotFound(w, r)
return
}
if !cf.relation.Many {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
var in AdminIDsRequest
if err := s.decodeStrictBody(w, r, &in); err != nil {
s.writeFileError(w, r, cf, nil, err)
return
}
bucket := s.bucket()
var items []FileItem
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
files, _, err := sc.visibleFiles(tx)
if err != nil {
return err
}
byID := make(map[uint]int, len(files))
orders := make([]int, len(files))
for i, f := range files {
byID[f.ID] = i
orders[i] = f.SortOrder
}
seen := map[uint]bool{}
valid := len(in.IDs) == len(files)
for _, raw := range in.IDs {
id := uint(raw)
if _, known := byID[id]; !known || seen[id] || uint64(id) != raw {
valid = false
break
}
seen[id] = true
}
if !valid {
return &ValidationError{Details: map[string]any{"ids": []string{"The ids field must list every file of the field exactly once."}}}
}
slices.Sort(orders)
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
for i, raw := range in.IDs {
if err := q.Model(&attach.File{}).Where("id = ?", uint(raw)).Update("sort_order", orders[i]).Error; err != nil {
return err
}
}
files, pending, err := sc.visibleFiles(tx)
if err != nil {
return err
}
items = make([]FileItem, 0, len(files))
for i := range files {
items = append(items, fileItem(ctx, bucket, cf, &files[i], pending[files[i].ID]))
}
return nil
})
if ok {
WriteData(w, http.StatusOK, items, nil)
}
})
}
// fileDownload serves GET .../{id}/files/{field}/{file}/download.
func (s *service) fileDownload(w http.ResponseWriter, r *http.Request) {
s.serveProtectedFile(w, r, false)
}
// fileThumb serves GET .../{id}/files/{field}/{file}/thumb.
func (s *service) fileThumb(w http.ResponseWriter, r *http.Request) {
s.serveProtectedFile(w, r, true)
}
// serveProtectedFile streams a protected (is_public false) file or its
// thumbnail (D-10). The file must belong to a record the admin may load
// through FormExtendQuery, or be pending in the admin's own session; a
// public file, a file of another record and a thumbnail of a non-image are
// 404. Only JPEG, PNG, GIF and WebP are served inline; everything else is an
// application/octet-stream attachment. Every response is nosniff, private
// and no-store, under a sandboxing CSP.
func (s *service) serveProtectedFile(w http.ResponseWriter, r *http.Request, thumb bool) {
s.protect(w, r, func(cc *CompiledController) {
cf := cc.files[r.PathValue("field")]
if cf == nil {
writeNotFound(w, r)
return
}
fileID, err := pathFileID(r)
if err != nil {
writeCRUDError(w, err)
return
}
bucket := s.bucket()
if bucket == nil {
slog.Default().ErrorContext(r.Context(), "cabana: protected file route without a storage bucket", "controller", controllerID(cc))
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
var f *attach.File
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
found, err := sc.findFile(ctx, tx, fileID, false)
if err != nil {
return err
}
if found.Public() {
return recordNotFound{}
}
f = found
return nil
})
if !ok {
return
}
ctx := r.Context()
key := attach.BlobKey(f.DiskName)
contentType := f.ContentType
if thumb {
if !slices.Contains(attach.AllowedImageMIMEs, f.ContentType) {
writeNotFound(w, r)
return
}
key, err = f.ThumbKey(ctx, bucket, cf.thumbW, cf.thumbH, cf.thumbMode)
if err != nil {
slog.Default().ErrorContext(ctx, "cabana: protected thumbnail failed", "file_id", f.ID, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
contentType = ""
}
reader, err := bucket.NewReader(ctx, key, nil)
if err != nil {
if gcerrors.Code(err) == gcerrors.NotFound {
writeNotFound(w, r)
return
}
slog.Default().ErrorContext(ctx, "cabana: protected file read failed", "file_id", f.ID, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
defer reader.Close()
if contentType == "" {
contentType = reader.ContentType()
}
contentType = strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Cache-Control", "private, no-store")
h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
if slices.Contains(attach.AllowedImageMIMEs, contentType) {
h.Set("Content-Type", contentType)
} else {
h.Set("Content-Type", "application/octet-stream")
h.Set("Content-Disposition", "attachment; filename*=UTF-8''"+rfc5987(f.FileName))
}
h.Set("Content-Length", strconv.FormatInt(reader.Size(), 10))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, reader)
})
}
// rfc5987 percent-encodes a file name for a filename* parameter: only
// RFC 5987 attr-char bytes stay literal.
func rfc5987(name string) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(name); i++ {
c := name[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9',
strings.IndexByte("!#$&+-.^_`|~", c) >= 0:
b.WriteByte(c)
default:
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&15])
}
}
if b.Len() == 0 {
return "file"
}
return b.String()
}

View File

@@ -1,7 +1,9 @@
package cabana_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -9,6 +11,8 @@ import (
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gorm.io/gorm"
)
func fileList(t *testing.T, rec *httptest.ResponseRecorder) []cabana.FileItem {
@@ -116,3 +120,189 @@ func TestFileuploadSmokeForeignAdmin(t *testing.T) {
t.Fatalf("owner's binding rows = %d, want 1", n)
}
}
func (e *conformEnv) createGadget(t *testing.T, name, key string) uint {
t.Helper()
payload, _ := json.Marshal(map[string]any{"name": name})
headers := map[string]string{}
if key != "" {
headers[cabana.SessionKeyHeader] = key
}
rec := e.sendWith(t, http.MethodPost, "/acme/conform/gadgets", payload, "application/json", headers)
if rec.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
}
return dataID(t, rec.Body.Bytes())
}
func (e *conformEnv) saveGadget(t *testing.T, id uint, name, key string) *httptest.ResponseRecorder {
t.Helper()
payload, _ := json.Marshal(map[string]any{"name": name})
return e.sendWith(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", id), payload, "application/json", map[string]string{cabana.SessionKeyHeader: key})
}
func (e *conformEnv) storedFile(t *testing.T, id uint) (attach.File, bool) {
t.Helper()
var f attach.File
err := e.db.Where("id = ?", id).Take(&f).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return attach.File{}, false
}
if err != nil {
t.Fatal(err)
}
return f, true
}
func (e *conformEnv) blobExists(t *testing.T, diskName string) bool {
t.Helper()
ok, err := e.bucket.Exists(context.Background(), attach.BlobKey(diskName))
if err != nil {
t.Fatal(err)
}
return ok
}
// TestFileuploadSmokeRemoveCancelsPending removes a pending upload: its
// row is deleted and, after commit, its blob.
func TestFileuploadSmokeRemoveCancelsPending(t *testing.T) {
env := newConformEnv(t)
env.loginAs(t, env.login)
key := newSessionKey(t)
up := env.upload(t, 0, "photos", "photo.png", conformPNG(t), key)
if up.Code != http.StatusCreated {
t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String())
}
id := dataID(t, up.Body.Bytes())
f, ok := env.storedFile(t, id)
if !ok || !env.blobExists(t, f.DiskName) {
t.Fatal("upload left no row or blob")
}
rec := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", id), nil, "", map[string]string{cabana.SessionKeyHeader: key})
if rec.Code != http.StatusOK {
t.Fatalf("remove status=%d body=%s", rec.Code, rec.Body.String())
}
if _, ok := env.storedFile(t, id); ok {
t.Fatal("cancelled upload row still exists")
}
if env.blobExists(t, f.DiskName) {
t.Fatal("cancelled upload blob still exists")
}
if n := env.bindingCount(t, key); n != 0 {
t.Fatalf("bindings after cancel = %d", n)
}
// The same file again is out of scope.
again := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", id), nil, "", map[string]string{cabana.SessionKeyHeader: key})
if again.Code != http.StatusNotFound {
t.Fatalf("second remove status=%d", again.Code)
}
}
// TestFileuploadSmokeAttachOneReplace saves a second file into an attachOne
// field: the first file's row and blob are gone after the save, and a
// deferred removal of an attached file applies on the next save.
func TestFileuploadSmokeAttachOneReplace(t *testing.T) {
env := newConformEnv(t)
env.loginAs(t, env.login)
gadget := env.createGadget(t, "replace-"+env.stamp, "")
first := newSessionKey(t)
upA := env.upload(t, gadget, "manual", "a.txt", []byte("first manual\n"), first)
if upA.Code != http.StatusCreated {
t.Fatalf("upload a status=%d body=%s", upA.Code, upA.Body.String())
}
if rec := env.saveGadget(t, gadget, "replace-"+env.stamp, first); rec.Code != http.StatusOK {
t.Fatalf("save a status=%d body=%s", rec.Code, rec.Body.String())
}
a, _ := env.storedFile(t, dataID(t, upA.Body.Bytes()))
if got := env.listFiles(t, gadget, "manual", ""); len(got) != 1 || got[0].ID != a.ID || got[0].URL != "" {
t.Fatalf("after first save = %#v", got)
}
second := newSessionKey(t)
upB := env.upload(t, gadget, "manual", "b.txt", []byte("second manual\n"), second)
if upB.Code != http.StatusCreated {
t.Fatalf("upload b status=%d body=%s", upB.Code, upB.Body.String())
}
if rec := env.saveGadget(t, gadget, "replace-"+env.stamp, second); rec.Code != http.StatusOK {
t.Fatalf("save b status=%d body=%s", rec.Code, rec.Body.String())
}
got := env.listFiles(t, gadget, "manual", "")
if len(got) != 1 || got[0].ID != dataID(t, upB.Body.Bytes()) {
t.Fatalf("after replace = %#v", got)
}
if _, ok := env.storedFile(t, a.ID); ok {
t.Fatal("replaced attachOne row still exists")
}
if env.blobExists(t, a.DiskName) {
t.Fatal("replaced attachOne blob still exists")
}
// A deferred removal hides the file in the session and deletes it on save.
third := newSessionKey(t)
b, _ := env.storedFile(t, got[0].ID)
if rec := env.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d", gadget, b.ID), nil, "", map[string]string{cabana.SessionKeyHeader: third}); rec.Code != http.StatusOK {
t.Fatalf("remove status=%d body=%s", rec.Code, rec.Body.String())
}
if len(env.listFiles(t, gadget, "manual", third)) != 0 || len(env.listFiles(t, gadget, "manual", "")) != 1 {
t.Fatal("deferred removal is not scoped to its session")
}
if rec := env.saveGadget(t, gadget, "replace-"+env.stamp, third); rec.Code != http.StatusOK {
t.Fatalf("save removal status=%d body=%s", rec.Code, rec.Body.String())
}
if _, ok := env.storedFile(t, b.ID); ok || env.blobExists(t, b.DiskName) {
t.Fatal("deferred removal did not delete the file and its blob")
}
}
// TestProtectedFileSmoke downloads protected files through the admin route:
// a file of another record is 404, a public file is 404, and an SVG stored
// in file mode is an octet-stream attachment with nosniff.
func TestProtectedFileSmoke(t *testing.T) {
env := newConformEnv(t)
env.loginAs(t, env.login)
owner := env.createGadget(t, "owner-"+env.stamp, "")
other := env.createGadget(t, "other-"+env.stamp, "")
key := newSessionKey(t)
svg := []byte(`<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.
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))
constrainFile(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", requireAjax(s.fileReorder))
constrainFile(g)
g.Put("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", requireAjax(s.fileUpdate))
constrainFileID(g)
g.Delete("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", requireAjax(s.fileRemove))
constrainFileID(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download", s.fileDownload)
constrainFileID(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb", s.fileThumb)
constrainFileID(g)
})
// The SPA shell: public, no guard. ServeMux prefers every API pattern
// above over the {path...} wildcard.
@@ -309,6 +319,11 @@ func constrainFile(g pact.Router) {
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
}
func constrainFileID(g pact.Router) {
constrainFile(g)
g.Where("file", "[0-9]+")
}
func constrainNested(g pact.Router) {
constrainController(g)
g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*")
@@ -787,7 +802,7 @@ func (s *service) crud() (CRUDService, error) {
if err != nil {
return CRUDService{}, err
}
return CRUDService{DB: db}, nil
return CRUDService{DB: db, bucket: s.bucket(), tr: s.translator()}, nil
}
func (s *service) list(w http.ResponseWriter, r *http.Request) {

View File

@@ -45,6 +45,22 @@ type conformCase struct {
ref string
call func(t *testing.T, env *conformEnv) *httptest.ResponseRecorder
decode func(dec *json.Decoder) error
// raw, when set, checks a binary response instead of decoding JSON;
// admin.json must document the success as binary.
raw func(t *testing.T, rec *httptest.ResponseRecorder)
}
// binaryFile checks a protected file response: status, content type and
// the D-10 headers.
func binaryFile(contentType string) func(t *testing.T, rec *httptest.ResponseRecorder) {
return func(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
h := rec.Header()
if h.Get("Content-Type") != contentType || h.Get("X-Content-Type-Options") != "nosniff" ||
h.Get("Cache-Control") != "private, no-store" || !strings.Contains(h.Get("Content-Security-Policy"), "sandbox") || rec.Body.Len() == 0 {
t.Fatalf("protected file headers=%v len=%d", h, rec.Body.Len())
}
}
}
func into[T any]() func(*json.Decoder) error {
@@ -66,33 +82,33 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
cases := []conformCase{
{"POST /auth/login", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/auth/login", map[string]string{"login": e.login, "password": adminTestPassword}, false)
}, into[cabana.Envelope[cabana.AdminLoginData]]()},
}, into[cabana.Envelope[cabana.AdminLoginData]](), nil},
{"POST /auth/refresh", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/auth/refresh", nil, true)
e.token = accessToken(t, rec.Body.Bytes())
return rec
}, into[cabana.Envelope[cabana.AdminLoginData]]()},
}, into[cabana.Envelope[cabana.AdminLoginData]](), nil},
{"GET /auth/me", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/auth/me", nil, true)
}, into[cabana.Envelope[cabana.AdminProfile]]()},
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
{"GET /lang", 200, "cabana.Envelope-cabana_LangBundle", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/lang", nil, false)
}, into[cabana.Envelope[cabana.LangBundle]]()},
}, into[cabana.Envelope[cabana.LangBundle]](), nil},
{"GET /navigation", 200, "cabana.Envelope-array_cabana_NavigationEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/navigation", nil, true)
}, into[cabana.Envelope[[]cabana.NavigationEntry]]()},
}, into[cabana.Envelope[[]cabana.NavigationEntry]](), nil},
{"GET /settings", 200, "cabana.Envelope-array_cabana_SettingsEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings", nil, true)
}, into[cabana.Envelope[[]cabana.SettingsEntry]]()},
}, into[cabana.Envelope[[]cabana.SettingsEntry]](), nil},
{"GET /settings/{code}/schema", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings/conform/schema", nil, true)
}, into[cabana.Envelope[cabana.FormView]]()},
}, into[cabana.Envelope[cabana.FormView]](), nil},
{"GET /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings/conform", nil, true)
}, into[cabana.Envelope[cabana.SettingsResult]]()},
}, into[cabana.Envelope[cabana.SettingsResult]](), nil},
{"PUT /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true)
}, into[cabana.Envelope[cabana.SettingsResult]]()},
}, into[cabana.Envelope[cabana.SettingsResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true)
var body cabana.Envelope[cabana.ListSchema]
@@ -108,22 +124,24 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
e.assertPluginAsset(t, body.Data.Assets.Scripts[0], "text/javascript; charset=utf-8")
e.assertPluginAsset(t, body.Data.Assets.Styles[0], "text/css; charset=utf-8")
return rec
}, into[cabana.Envelope[cabana.ListSchema]]()},
}, into[cabana.Envelope[cabana.ListSchema]](), nil},
{"GET /{vendor}/{plugin}/{controller}/schema/form", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/form", nil, true)
}, into[cabana.Envelope[cabana.FormView]]()},
}, into[cabana.Envelope[cabana.FormView]](), nil},
{"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", 200, "cabana.Envelope-cabana_RelationSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/relation/members", nil, true)
}, into[cabana.Envelope[cabana.RelationSchema]]()},
}, into[cabana.Envelope[cabana.RelationSchema]](), nil},
{"GET /{vendor}/{plugin}/{controller}/fields/{field}/options", 200, "cabana.ListEnvelope-array_cabana_RelationOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/fields/group/options?search="+e.stamp, nil, true)
}, into[cabana.ListEnvelope[[]cabana.RelationOption]]()},
}, into[cabana.ListEnvelope[[]cabana.RelationOption]](), nil},
{"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", 200, "cabana.Envelope-array_cabana_FilterOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/filters/grouped/options", nil, true)
}, into[cabana.Envelope[[]cabana.FilterOption]]()},
}, into[cabana.Envelope[[]cabana.FilterOption]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", 201, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.upload(t, 0, "photos", "photo.png", conformPNG(t), e.sessionKey)
}, into[cabana.Envelope[cabana.FileItem]]()},
rec := e.upload(t, 0, "photos", "photo.png", conformPNG(t), e.sessionKey)
e.photoID = dataID(t, rec.Body.Bytes())
return rec
}, into[cabana.Envelope[cabana.FileItem]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.sendWith(t, http.MethodGet, "/acme/conform/gadgets/0/files/photos", nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
var body cabana.Envelope[[]cabana.FileItem]
@@ -131,12 +149,34 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
t.Fatalf("pending file list = %s (%v)", rec.Body.String(), err)
}
return rec
}, into[cabana.Envelope[[]cabana.FileItem]]()},
}, into[cabana.Envelope[[]cabana.FileItem]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", 200, "cabana.Envelope-array_cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]any{"ids": []uint{e.photoID}})
return e.sendWith(t, http.MethodPost, "/acme/conform/gadgets/0/files/photos/reorder", body, "application/json", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
}, into[cabana.Envelope[[]cabana.FileItem]](), nil},
{"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", e.photoID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
}, into[cabana.Envelope[cabana.FileMutationResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID}, true)
e.gadgetID = dataID(t, rec.Body.Bytes())
return rec
}, into[cabana.RecordEnvelope]()},
}, into[cabana.RecordEnvelope](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
up := e.upload(t, e.gadgetID, "manual", "manual.png", conformPNG(t), e.sessionKey)
if up.Code != http.StatusCreated {
t.Fatalf("manual upload status=%d body=%s", up.Code, up.Body.String())
}
e.manualID = dataID(t, up.Body.Bytes())
body, _ := json.Marshal(map[string]any{"title": "Manual " + e.stamp})
return e.sendWith(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d", e.gadgetID, e.manualID), body, "application/json", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
}, into[cabana.Envelope[cabana.FileItem]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/download", e.gadgetID, e.manualID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
}, nil, binaryFile("image/png")},
{"GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb", 200, "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/manual/%d/thumb", e.gadgetID, e.manualID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
}, nil, binaryFile("image/png")},
{"POST /{vendor}/{plugin}/{controller}/widgets/{field}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets/widgets/lookup", map[string]any{"record_id": e.gadgetID, "values": map[string]any{"name": "x", "active": false}}, true)
// The fixture action also returns active, which is outside the
@@ -149,7 +189,7 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
t.Fatalf("widget fill = %#v, want only name", body.Data.Fill)
}
return rec
}, into[cabana.Envelope[cabana.AdminActionResult]]()},
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/partials/{name}", 200, "cabana.Envelope-cabana_PartialView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/partials/summary?id=%d", e.gadgetID), nil, true)
if !strings.Contains(rec.Body.String(), `"text":"gadget-`+e.stamp+`"`) {
@@ -160,41 +200,41 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
t.Fatalf("header partial status=%d body=%s", header.Code, header.Body.String())
}
return rec
}, into[cabana.Envelope[cabana.PartialView]]()},
}, into[cabana.Envelope[cabana.PartialView]](), nil},
{"GET /{vendor}/{plugin}/{controller}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
}, into[cabana.RecordEnvelope]()},
}, into[cabana.RecordEnvelope](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), map[string]any{"name": "gadget-" + e.stamp + "-renamed", "group": nil}, true)
}, into[cabana.RecordEnvelope]()},
}, into[cabana.RecordEnvelope](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/candidates?search=%s", e.gadgetID, e.stamp), nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/link", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
}, into[cabana.Envelope[cabana.RelationMutationResult]]()},
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members", e.gadgetID), nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
}, into[cabana.Envelope[cabana.RelationMutationResult]]()},
}, into[cabana.Envelope[cabana.RelationMutationResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true)
}, into[cabana.Envelope[cabana.AdminActionResult]]()},
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}/bulk-delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true)
return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true)
}, into[cabana.Envelope[cabana.BulkResult]]()},
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
{"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
}, into[cabana.Envelope[cabana.BulkResult]]()},
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
}, into[cabana.Envelope[cabana.AdminLogoutData]]()},
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},
}
inventory := map[string]bool{}
@@ -223,12 +263,19 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
if rec.Code != tc.status {
t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
}
method, path, _ := strings.Cut(tc.key, " ")
if tc.raw != nil {
tc.raw(t, rec)
if !spec.binary(path, strings.ToLower(method), fmt.Sprint(tc.status)) {
t.Fatalf("admin.json does not document %s %d as binary", tc.key, tc.status)
}
return
}
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.DisallowUnknownFields()
if err := tc.decode(dec); err != nil {
t.Fatalf("body does not match its documented type: %v\n%s", err, rec.Body.String())
}
method, path, _ := strings.Cut(tc.key, " ")
got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status))
if got != tc.ref {
t.Fatalf("admin.json documents %q for %s %d, the handler writes %s", got, tc.key, tc.status, tc.ref)
@@ -245,13 +292,20 @@ type conformSpecDoc struct {
Responses map[string]struct {
Content map[string]struct {
Schema struct {
Ref string `json:"$ref"`
Ref string `json:"$ref"`
Type string `json:"type"`
Format string `json:"format"`
} `json:"schema"`
} `json:"content"`
} `json:"responses"`
} `json:"paths"`
}
func (d conformSpecDoc) binary(path, method, status string) bool {
schema := d.Paths[path][method].Responses[status].Content["application/octet-stream"].Schema
return schema.Type == "string" && schema.Format == "binary"
}
func (d conformSpecDoc) ref(path, method, status string) string {
ref := d.Paths[path][method].Responses[status].Content["application/json"].Schema.Ref
return strings.TrimPrefix(ref, "#/components/schemas/")
@@ -282,6 +336,8 @@ type conformEnv struct {
token string
stamp string
sessionKey string
photoID uint
manualID uint
groupID uint
memberID uint
gadgetID uint
@@ -491,7 +547,7 @@ type conformGadget struct {
func (conformGadget) TableName() string { return "cabana_conform_gadgets" }
func (conformGadget) MorphName() string { return "acme.conform.gadget" }
func (conformGadget) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: "photos", Many: true, Public: true}}
return []attach.Relation{{Name: "photos", Many: true, Public: true}, {Name: "manual"}}
}
func (conformGadget) Fillable() []string { return []string{"name", "active"} }
func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
@@ -733,6 +789,12 @@ update:
maxFilesize: 0.5
thumbOptions:
mode: crop
manual:
label: Manual
type: fileupload
fileTypes: [pdf, png, svg, txt]
useCaption: true
context: update
`),
"models/settings/fields.yaml": file(`fields:
enabled:

View File

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

View File

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

View File

@@ -64,6 +64,11 @@ var phase09Routes = []adminRoute{
{key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", mounted: nestedGetRoute},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}"},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"},
{key: "PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}"},
{key: "DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb"},
{key: "GET /assets/{vendor}/{plugin}/{file...}", public: true, spa: true},
{key: "GET ", public: true, spa: true},
{key: "GET /{path...}", public: true, spa: true},
@@ -290,6 +295,11 @@ func phase09ProtectedCalls() []phase09Call {
{"relation-unlink", (*service).relationUnlink},
{"file-list", (*service).fileList},
{"file-upload", (*service).fileUpload},
{"file-reorder", (*service).fileReorder},
{"file-update", (*service).fileUpdate},
{"file-remove", (*service).fileRemove},
{"file-download", (*service).fileDownload},
{"file-thumb", (*service).fileThumb},
{"settings-schema", (*service).settingsSchema},
{"settings-get", (*service).settingsGet},
{"settings-put", (*service).settingsPut},