diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index a374404..8cd898c 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -191,6 +191,24 @@ ], "type": "object" }, + "cabana.Envelope-array_cabana_FileItem": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/cabana.FileItem" + }, + "type": "array" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-array_cabana_FilterOption": { "properties": { "data": { @@ -320,6 +338,21 @@ ], "type": "object" }, + "cabana.Envelope-cabana_FileItem": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.FileItem" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-cabana_FormView": { "properties": { "data": { @@ -456,6 +489,55 @@ ], "type": "object" }, + "cabana.FileItem": { + "properties": { + "content_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "file_name": { + "type": "string" + }, + "file_size": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "pending": { + "type": "boolean" + }, + "sort_order": { + "type": "integer" + }, + "thumb_url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "content_type", + "created_at", + "description", + "file_name", + "file_size", + "id", + "pending", + "sort_order", + "title" + ], + "type": "object" + }, "cabana.FilterOption": { "properties": { "label": { @@ -499,6 +581,13 @@ "emptyOption": { "type": "string" }, + "fileTypes": { + "description": "FileTypes are the allowed lower-case extensions of a fileupload field.", + "items": { + "type": "string" + }, + "type": "array" + }, "fill": { "description": "Fill lists the fields of the same form the action writes back (D-07).", "items": { @@ -506,9 +595,35 @@ }, "type": "array" }, + "imageHeight": { + "type": "integer" + }, + "imageWidth": { + "description": "ImageWidth and ImageHeight are the preview size of an image upload.", + "type": "integer" + }, "label": { "type": "string" }, + "maxFiles": { + "description": "MaxFiles caps the number of files of an attachMany field.", + "type": "integer" + }, + "maxFilesize": { + "description": "MaxFilesize is the largest accepted file in megabytes.", + "type": "number" + }, + "mimeTypes": { + "description": "MimeTypes are the allowed MIME patterns (or extensions) of a\nfileupload field.", + "items": { + "type": "string" + }, + "type": "array" + }, + "mode": { + "description": "Mode is the fileupload mode (image or file, default file).", + "type": "string" + }, "multiple": { "type": "boolean" }, @@ -528,6 +643,14 @@ "description": "Path names the controller partial of a `type: partial` field: the\ntemplate {ConfigDir}/_{path}.htm (D-09).", "type": "string" }, + "prompt": { + "description": "Prompt is the upload button text, localized per request.", + "type": "string" + }, + "protected": { + "description": "Protected is true for a fileupload field whose relation is not\npublic: its files are served only through the admin file routes.", + "type": "boolean" + }, "readOnly": { "type": "boolean" }, @@ -546,9 +669,21 @@ "tab": { "type": "string" }, + "thumbOptions": { + "allOf": [ + { + "$ref": "#/components/schemas/cabana.ThumbOptions" + } + ], + "description": "ThumbOptions carries the preview thumbnail mode." + }, "type": { "type": "string" }, + "useCaption": { + "description": "UseCaption lets the admin edit each file's title and description.", + "type": "boolean" + }, "widget": { "description": "Widget is the custom-element tag of a `type: widget` field (D-06).", "type": "string" @@ -1384,6 +1519,17 @@ }, "type": "object" }, + "cabana.ThumbOptions": { + "properties": { + "mode": { + "type": "string" + } + }, + "required": [ + "mode" + ], + "type": "object" + }, "cabana.ToolbarAction": { "properties": { "label": { @@ -2178,6 +2324,14 @@ "schema": { "type": "string" } + }, + { + "description": "Form session key: the save attaches the files uploaded under it", + "in": "header", + "name": "X-Session-Key", + "schema": { + "type": "string" + } } ], "requestBody": { @@ -3414,6 +3568,14 @@ "schema": { "type": "integer" } + }, + { + "description": "Form session key: the save applies the file uploads and removals held against it", + "in": "header", + "name": "X-Session-Key", + "schema": { + "type": "string" + } } ], "requestBody": { @@ -3490,6 +3652,277 @@ ] } }, + "/{vendor}/{plugin}/{controller}/{id}/files/{field}": { + "get": { + "description": "The files attached to the record minus the session's pending removals, plus the session's pending uploads, in sort_order. id 0 is the record being created in the X-Session-Key session (the key is then required). url and thumb_url are set only for a public relation.", + "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 (32-128 characters of A-Z a-z 0-9 _ -)", + "in": "header", + "name": "X-Session-Key", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-array_cabana_FileItem" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "List the files of a fileupload field", + "tags": [ + "admin" + ] + }, + "post": { + "description": "Stores one multipart file_data part and binds it to the X-Session-Key session; the record's next create or update save with the same key attaches it. id 0 is the record being created. A body over the upload cap answers 413 payload_too_large; a file over maxFilesize, of a type the field does not allow, or that fails the image check answers 422 on the field.", + "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 (32-128 characters of A-Z a-z 0-9 _ -)", + "in": "header", + "name": "X-Session-Key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "file_data": { + "description": "The file", + "format": "binary", + "type": "string" + } + }, + "required": [ + "file_data" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_FileItem" + } + } + }, + "description": "Created" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Request Entity Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Upload a file to a fileupload field", + "tags": [ + "admin" + ] + } + }, "/{vendor}/{plugin}/{controller}/{id}/relations/{name}": { "get": { "parameters": [ diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 49683eb..a617836 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -695,7 +695,10 @@ export interface paths { post: { parameters: { query?: never; - header?: never; + header?: { + /** @description Form session key: the save attaches the files uploaded under it */ + "X-Session-Key"?: string; + }; path: { /** @description Vendor */ vendor: string; @@ -1561,7 +1564,10 @@ export interface paths { put: { parameters: { query?: never; - header?: never; + header?: { + /** @description Form session key: the save applies the file uploads and removals held against it */ + "X-Session-Key"?: string; + }; path: { /** @description Vendor */ vendor: string; @@ -1700,6 +1706,187 @@ export interface paths { patch?: never; trace?: never; }; + "/{vendor}/{plugin}/{controller}/{id}/files/{field}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the files of a fileupload field + * @description The files attached to the record minus the session's pending removals, plus the session's pending uploads, in sort_order. id 0 is the record being created in the X-Session-Key session (the key is then required). url and thumb_url are set only for a public relation. + */ + get: { + parameters: { + query?: never; + header?: { + /** @description Form session key (32-128 characters of A-Z a-z 0-9 _ -) */ + "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; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-array_cabana_FileItem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + put?: never; + /** + * Upload a file to a fileupload field + * @description Stores one multipart file_data part and binds it to the X-Session-Key session; the record's next create or update save with the same key attaches it. id 0 is the record being created. A body over the upload cap answers 413 payload_too_large; a file over maxFilesize, of a type the field does not allow, or that fails the image check answers 422 on the field. + */ + post: { + parameters: { + query?: never; + header: { + /** @description Form session key (32-128 characters of A-Z a-z 0-9 _ -) */ + "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; + }; + requestBody: { + content: { + "multipart/form-data": { + /** + * Format: binary + * @description The file + */ + file_data: string; + }; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_FileItem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/{vendor}/{plugin}/{controller}/{id}/relations/{name}": { parameters: { query?: never; @@ -2124,6 +2311,10 @@ export interface components { scripts: string[]; styles: string[]; }; + "cabana.Envelope-array_cabana_FileItem": { + data: components["schemas"]["cabana.FileItem"][]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-array_cabana_FilterOption": { data: components["schemas"]["cabana.FilterOption"][]; meta: components["schemas"]["cabana.SuccessMeta"]; @@ -2156,6 +2347,10 @@ export interface components { data: components["schemas"]["cabana.BulkResult"]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_FileItem": { + data: components["schemas"]["cabana.FileItem"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-cabana_FormView": { data: components["schemas"]["cabana.FormView"]; meta: components["schemas"]["cabana.SuccessMeta"]; @@ -2194,6 +2389,19 @@ export interface components { "cabana.ErrorEnvelope": { error: components["schemas"]["cabana.ErrorBody"]; }; + "cabana.FileItem": { + content_type: string; + created_at: string; + description: string; + file_name: string; + file_size: number; + id: number; + pending: boolean; + sort_order: number; + thumb_url?: string; + title: string; + url?: string; + }; "cabana.FilterOption": { label: string; value: string; @@ -2210,9 +2418,25 @@ export interface components { context?: components["schemas"]["cabana.fieldContext"]; default?: components["schemas"]["cabana.jsonScalar"]; emptyOption?: string; + /** @description FileTypes are the allowed lower-case extensions of a fileupload field. */ + fileTypes?: string[]; /** @description Fill lists the fields of the same form the action writes back (D-07). */ fill?: string[]; + imageHeight?: number; + /** @description ImageWidth and ImageHeight are the preview size of an image upload. */ + imageWidth?: number; label?: string; + /** @description MaxFiles caps the number of files of an attachMany field. */ + maxFiles?: number; + /** @description MaxFilesize is the largest accepted file in megabytes. */ + maxFilesize?: number; + /** + * @description MimeTypes are the allowed MIME patterns (or extensions) of a + * fileupload field. + */ + mimeTypes?: string[]; + /** @description Mode is the fileupload mode (image or file, default file). */ + mode?: string; multiple?: boolean; name: string; nameFrom?: string; @@ -2222,13 +2446,24 @@ export interface components { * template {ConfigDir}/_{path}.htm (D-09). */ path?: string; + /** @description Prompt is the upload button text, localized per request. */ + prompt?: string; + /** + * @description Protected is true for a fileupload field whose relation is not + * public: its files are served only through the admin file routes. + */ + protected?: boolean; readOnly?: boolean; relation?: string; required?: boolean; size?: string; span?: string; tab?: string; + /** @description ThumbOptions carries the preview thumbnail mode. */ + thumbOptions?: components["schemas"]["cabana.ThumbOptions"]; type: string; + /** @description UseCaption lets the admin edit each file's title and description. */ + useCaption?: boolean; /** @description Widget is the custom-element tag of a `type: widget` field (D-06). */ widget?: string; }; @@ -2469,6 +2704,9 @@ export interface components { per_page?: number; total?: number; }; + "cabana.ThumbOptions": { + mode: string; + }; "cabana.ToolbarAction": { label: string; name: string; diff --git a/docs/backend/forms.md b/docs/backend/forms.md index 5a01fbb..4bcecc3 100644 --- a/docs/backend/forms.md +++ b/docs/backend/forms.md @@ -91,8 +91,9 @@ for _, f := range form.Fields { | `relation-manager` | An embedded list of related records; see [Relation manager](relation-manager.md). | | `widget` | A plugin custom element with a server action; see [Partials and widgets](partials-and-widgets.md). | | `partial` | A server-rendered template; see [Partials and widgets](partials-and-widgets.md). | +| `fileupload` | Uploads for an attachOne or attachMany relation; see [File uploads](#file-uploads). | -The WinterCMS widgets that are not in this list (the rich editor, the media finder, the repeater, the file upload and the others) are not provided. A field with one of those types stops the start-up. +The WinterCMS widgets that are not in this list (the rich editor, the media finder, the repeater and the others) are not provided. A field with one of those types stops the start-up. ### Field options @@ -100,6 +101,43 @@ A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` ( `context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted. The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds. +## File uploads + +A `type: fileupload` field edits one attachment relation of the form's model. The model declares its relations with an `AttachRelations` method (`attach.HasRelations`) and implements `attach.Owner`; see [Attachments](../database/attachments.md). The field name must be one of the declared relation names, otherwise the start-up stops. + +```yaml +fields: + photos: + label: acme.blog::lang.posts.photos + type: fileupload + mode: image + fileTypes: jpg,png,webp + maxFilesize: 5 + maxFiles: 10 + thumbOptions: + mode: crop +``` + +The field takes the generic keys plus these WinterCMS keys: + +| Key | Meaning | +|-----|---------| +| `mode` | `image` or `file` (the default). Image mode accepts only jpg, jpeg, png, gif and webp and checks that the bytes decode as such an image. | +| `fileTypes` | Allowed extensions, as a comma- or pipe-separated string or a list. | +| `mimeTypes` | Allowed MIME types (`image/png`, `image/*`) or extensions. | +| `maxFilesize` | The largest file in megabytes. It may not exceed `http.body_limits.upload_bytes`. | +| `maxFiles` | The most files an attachMany relation may hold. Refused on attachOne. | +| `imageWidth`, `imageHeight` | Preview size, 1 to 4096 pixels (240 by 240 when not set). | +| `thumbOptions` | A mapping with `mode`: `auto`, `exact`, `crop` (the default) or `fit`. | +| `useCaption` | Lets the administrator edit each file's title and description. | +| `prompt` | The upload button text, a translation key. | + +`options`, `emptyOption`, `nameFrom` and every other key stop the start-up. + +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. + +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 The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. diff --git a/internal/tools/swagger2openapi/main.go b/internal/tools/swagger2openapi/main.go index 738e927..10058f3 100644 --- a/internal/tools/swagger2openapi/main.go +++ b/internal/tools/swagger2openapi/main.go @@ -183,12 +183,17 @@ func splitParameters(v any, consumes []string) (params []any, body map[string]an if !ok { return nil, nil } + var form map[string]any for _, item := range list { p, ok := item.(map[string]any) if !ok { params = append(params, item) continue } + if in, _ := p["in"].(string); in == "formData" { + form = addFormField(form, p) + continue + } if in, _ := p["in"].(string); in == "body" { content := map[string]any{} for _, ct := range consumes { @@ -209,9 +214,61 @@ func splitParameters(v any, consumes []string) (params []any, body map[string]an } params = append(params, convertParameter(p)) } + if form != nil && body == nil { + body = formBody(form, consumes) + } return params, body } +// addFormField folds one Swagger 2.0 formData parameter into an object +// schema: a file becomes a binary string, any other type keeps its schema. +func addFormField(form map[string]any, p map[string]any) map[string]any { + if form == nil { + form = map[string]any{"type": "object", "properties": map[string]any{}} + } + name, _ := p["name"].(string) + prop := map[string]any{} + for k, v := range p { + if _, ok := paramSchemaKeys[k]; ok { + prop[k] = v + } + } + if prop["type"] == "file" { + prop["type"] = "string" + prop["format"] = "binary" + } + if desc, ok := p["description"]; ok { + prop["description"] = desc + } + form["properties"].(map[string]any)[name] = prop + if req, _ := p["required"].(bool); req { + required, _ := form["required"].([]any) + form["required"] = append(required, name) + } + return form +} + +// formBody is the OpenAPI 3 requestBody of an operation's formData +// parameters, under multipart/form-data unless the operation declares only +// application/x-www-form-urlencoded. +func formBody(form map[string]any, consumes []string) map[string]any { + ct := "multipart/form-data" + for _, c := range consumes { + if c == "application/x-www-form-urlencoded" { + ct = c + } + if c == "multipart/form-data" { + ct = c + break + } + } + body := map[string]any{"content": map[string]any{ct: map[string]any{"schema": form}}} + if req, ok := form["required"].([]any); ok && len(req) > 0 { + body["required"] = true + } + return body +} + var paramSchemaKeys = map[string]struct{}{ "type": {}, "format": {}, "items": {}, "enum": {}, "default": {}, "minimum": {}, "maximum": {}, "minLength": {}, "maxLength": {}, diff --git a/internal/tools/swagger2openapi/main_test.go b/internal/tools/swagger2openapi/main_test.go index 4b32b9b..08a80b6 100644 --- a/internal/tools/swagger2openapi/main_test.go +++ b/internal/tools/swagger2openapi/main_test.go @@ -201,3 +201,36 @@ func TestMalformedShapesPassThrough(t *testing.T) { t.Fatalf("foreign ref rewritten: %v", got) } } + +func TestConvertFormData(t *testing.T) { + op := decode(t, `{ + "consumes": ["multipart/form-data"], + "parameters": [ + {"name": "id", "in": "path", "required": true, "type": "integer"}, + {"name": "file_data", "in": "formData", "required": true, "type": "file", "description": "The file"}, + {"name": "note", "in": "formData", "type": "string"} + ], + "responses": {"201": {"description": "Created"}} + }`) + out := convertOperation(op, nil, nil) + params := out["parameters"].([]any) + if len(params) != 1 || params[0].(map[string]any)["name"] != "id" { + t.Fatalf("parameters = %#v", params) + } + body := out["requestBody"].(map[string]any) + if body["required"] != true { + t.Fatalf("request body = %#v", body) + } + schema := body["content"].(map[string]any)["multipart/form-data"].(map[string]any)["schema"].(map[string]any) + want := map[string]any{ + "type": "object", + "properties": map[string]any{ + "file_data": map[string]any{"type": "string", "format": "binary", "description": "The file"}, + "note": map[string]any{"type": "string"}, + }, + "required": []any{"file_data"}, + } + if !reflect.DeepEqual(schema, want) { + t.Fatalf("form schema = %#v", schema) + } +} diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 0ac7f1b..23d5df4 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -18,6 +18,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte - Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file. - Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot. - Server-rendered partials: `headerPartial: ` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: ` in `fields.yaml` render the template `{ConfigDir}/_.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot. +- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` above `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation. - Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. - Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). - Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery. @@ -46,6 +47,8 @@ All paths are relative to `/api/v1`. A controller ID `vendor.plugin.cont | GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. | | GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. | | 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`. | 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. @@ -160,6 +163,11 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.AdminActionResult` | Answer of an action route: the localized `message` and the filtered `fill` object. | | `cabana.ControllerAssets` | The `assets` object of list and form schemas: `scripts` and `styles` URL lists, always arrays. | | `cabana.ToolbarAction` | One registered toolbar button in a list schema's `toolbarActions`: action name and localized label. | +| `cabana.SessionKeyHeader` | `X-Session-Key`, the header that carries the form session key of deferred file work. | +| `cabana.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.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). | ## Configuration diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index 68d5537..a7cceb9 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -359,6 +359,7 @@ func AdminList() {} // @Param plugin path string true "Plugin" // @Param controller path string true "Controller" // @Param body body AdminRecord true "Field values keyed by field name; relation fields carry ids" +// @Param X-Session-Key header string false "Form session key: the save attaches the files uploaded under it" // @Success 201 {object} RecordEnvelope // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope @@ -494,6 +495,7 @@ func AdminShow() {} // @Param controller path string true "Controller" // @Param id path integer true "Record id" // @Param body body AdminRecord true "Field values keyed by field name; relation fields carry ids" +// @Param X-Session-Key header string false "Form session key: the save applies the file uploads and removals held against it" // @Success 200 {object} RecordEnvelope // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope @@ -609,3 +611,54 @@ func AdminRelationLink() {} // @Failure 404 {object} ErrorEnvelope // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post] func AdminRelationUnlink() {} + +// FileMutationResult is the payload of a file removal: the number of files +// removed (always 1 on success). +type FileMutationResult struct { + Removed int `json:"removed"` +} + +// AdminFileList documents the file list of a fileupload field. +// +// @Summary List the files of a fileupload field +// @Description The files attached to the record minus the session's pending removals, plus the session's pending uploads, in sort_order. id 0 is the record being created in the X-Session-Key session (the key is then required). url and thumb_url are set only for a public relation. +// @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 X-Session-Key header string false "Form session key (32-128 characters of A-Z a-z 0-9 _ -)" +// @Success 200 {object} Envelope[[]FileItem] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field} [get] +func AdminFileList() {} + +// AdminFileUpload documents the upload of one file to a fileupload field. +// +// @Summary Upload a file to a fileupload field +// @Description Stores one multipart file_data part and binds it to the X-Session-Key session; the record's next create or update save with the same key attaches it. id 0 is the record being created. A body over the upload cap answers 413 payload_too_large; a file over maxFilesize, of a type the field does not allow, or that fails the image check answers 422 on the field. +// @Tags admin +// @Accept multipart/form-data +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param id path integer true "Owner id (0 for the record being created)" +// @Param field path string true "fileupload field name" +// @Param X-Session-Key header string true "Form session key (32-128 characters of A-Z a-z 0-9 _ -)" +// @Param file_data formData file true "The file" +// @Success 201 {object} Envelope[FileItem] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 413 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field} [post] +func AdminFileUpload() {} diff --git a/modules/cabana/auth.go b/modules/cabana/auth.go index 1de236d..c920acf 100644 --- a/modules/cabana/auth.go +++ b/modules/cabana/auth.go @@ -28,6 +28,7 @@ const ( msgForbidden = "Forbidden" msgNotFound = "Not found" msgServerError = "Server error" + msgPayloadTooLarge = "Payload too large" ) // BackendUsers loads activated backend principals. It never reads frontend users. diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index 408da0b..da2b929 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -89,6 +89,9 @@ type CompiledController struct { // fields declare, the only ones a request may render with a record id. partials map[string]*compiledPartial formPartials map[string]bool + // files are the form's `type: fileupload` fields bound to the model's + // attach.Relation, keyed by field name. + files map[string]*compiledFile } // Registry is the immutable controller map keyed by controller ID. diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index 688a94d..0fb24f9 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -22,9 +22,12 @@ type CRUDService struct { DB *gorm.DB } -// RecordInput is a decoded JSON object. Keys are untrusted. +// RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is +// the form's X-Session-Key (SessionKeyHeader), empty when the request has +// none: the save applies the file bindings held against it. type RecordInput struct { - Body map[string]any + Body map[string]any + SessionKey string } // BulkDeleteInput is the bulk-delete body. @@ -373,6 +376,9 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i if err := syncBelongsToMany(ctx, tx, cc, target, relations); err != nil { return err } + if err := s.commitDeferred(ctx, tx, cc, target, op, in); err != nil { + return err + } if update { err = formAfterUpdate(ctx, cc, target) } else { diff --git a/modules/cabana/deferred.go b/modules/cabana/deferred.go new file mode 100644 index 0000000..20f1b6a --- /dev/null +++ b/modules/cabana/deferred.go @@ -0,0 +1,115 @@ +package cabana + +import ( + "context" + "net/http" + "regexp" + "strconv" + "strings" + + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "gorm.io/gorm" +) + +// SessionKeyHeader carries the admin SPA's form session key (D-02): a +// random key the SPA generates when a form opens and sends with every file +// upload, file removal and the final save. Work bound to the key is applied +// by the record's next create or update save, inside its transaction. +const SessionKeyHeader = "X-Session-Key" + +// sessionKeyPattern is the accepted key shape: 32 to 128 URL-safe +// characters, at least 128 bits for a base64url key. +var sessionKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{32,128}$`) + +// sessionKeyFrom reads the X-Session-Key header. An absent or empty header +// is ("", false, nil); a malformed key is a validation error on session_key. +func sessionKeyFrom(r *http.Request) (string, bool, error) { + raw := strings.TrimSpace(r.Header.Get(SessionKeyHeader)) + if raw == "" { + return "", false, nil + } + if !sessionKeyPattern.MatchString(raw) { + return "", false, &ValidationError{Details: map[string]any{"session_key": []string{"The session key is invalid."}}} + } + return raw, true, nil +} + +// 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. +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 == "" { + return nil + } + principal, _ := bouncer.User(ctx) + if principal == nil || !principal.Backend || principal.ID == 0 { + return nil + } + fields := make([]string, 0, len(cc.files)) + for _, field := range cc.Form.Fields { + if cf := cc.files[field.Name]; cf != nil && contextAllows(cc, cf.name, op) { + fields = append(fields, cf.name) + } + } + if len(fields) == 0 { + 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 + } + if err := s.applyFileBind(ctx, tx, cf, morph, ownerID, row); err != nil { + return lifecycleFailure(cc, err) + } + applied = append(applied, row.ID) + } + if err := lagoon.DeferredForget(ctx, tx, applied); err != nil { + return lifecycleFailure(cc, err) + } + return nil +} + +// applyFileBind attaches a pending upload to the owner. A row that is gone +// or already attached somewhere is ignored. +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 { + return nil + } + f, err := lockFile(ctx, tx, uint(id)) + if err != nil || f == nil || f.AttachmentID != "" || f.AttachmentType != "" { + return err + } + return tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}). + Where("id = ?", f.ID). + Updates(map[string]any{"attachment_type": morph, "attachment_id": ownerID, "field": cf.name}).Error +} + +// 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 { + if n := pkUint(model); n > 0 { + return uitoa(n) + } + return "" +} diff --git a/modules/cabana/field_file.go b/modules/cabana/field_file.go new file mode 100644 index 0000000..8e6d203 --- /dev/null +++ b/modules/cabana/field_file.go @@ -0,0 +1,862 @@ +package cabana + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "math" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "github.com/goccy/go-yaml/ast" + "gocloud.dev/blob" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// fileuploadKeys are valid only on `type: fileupload` (D-08). mode is shared +// with other types and gated by value in compileFileuploadKeys. +var fileuploadKeys = []string{ + "fileTypes", "mimeTypes", "maxFilesize", "maxFiles", "imageWidth", + "imageHeight", "thumbOptions", "useCaption", "prompt", +} + +// fileuploadRefusedKeys are generic field keys that have no meaning on a +// fileupload field. +var fileuploadRefusedKeys = []string{"options", "emptyOption", "nameFrom"} + +var ( + fileTypePattern = regexp.MustCompile(`^[a-z0-9]{1,10}$`) + mimeTypePattern = regexp.MustCompile(`^[a-z0-9*][a-z0-9.+*-]*(/[a-z0-9*][a-z0-9.+*-]*)?$`) + thumbModes = map[string]struct{}{"auto": {}, "exact": {}, "crop": {}, "fit": {}} +) + +const ( + // defaultThumbEdge is the preview thumbnail edge when imageWidth and + // imageHeight are not set (A11). + defaultThumbEdge = 240 + // maxImageEdge bounds imageWidth and imageHeight, the thumbnailer's limit. + maxImageEdge = 4096 + // multipartOverhead is the room the upload body cap leaves above + // maxFilesize for the multipart framing. + multipartOverhead = 64 << 10 + // defaultUploadCap is the upload body cap when neither + // http.body_limits.upload_bytes nor maxFilesize is set. + defaultUploadCap = 128 << 20 + // megabyte is the unit of maxFilesize. + megabyte = 1 << 20 +) + +// compileFileuploadKeys decodes the D-08 keys of a `type: fileupload` field. +// They are refused on every other type. +func compileFileuploadKeys(typ string, values map[string]ast.Node, field *FormField) error { + if typ != "fileupload" { + for _, key := range fileuploadKeys { + if _, ok := values[key]; ok { + return fmt.Errorf("%s is only valid on type: fileupload", key) + } + } + if _, ok := values["mode"]; ok { + return fmt.Errorf("mode is only valid on type: fileupload") + } + return nil + } + for _, key := range fileuploadRefusedKeys { + if _, ok := values[key]; ok { + return fmt.Errorf("%s is not valid on type: fileupload", key) + } + } + field.Mode = "file" + if node, ok := values["mode"]; ok { + mode, err := nodeString(node) + if err != nil || (mode != "image" && mode != "file") { + return fmt.Errorf("mode %q must be image or file on type: fileupload", nodeText(node)) + } + field.Mode = mode + } + if node, ok := values["fileTypes"]; ok { + types, err := tokenList(node) + if err != nil { + return fmt.Errorf("fileTypes: %w", err) + } + for i, t := range types { + t = strings.ToLower(strings.TrimPrefix(t, ".")) + if !fileTypePattern.MatchString(t) { + return fmt.Errorf("fileTypes: %q is not a file extension", types[i]) + } + if field.Mode == "image" && !slices.Contains(attach.DefaultImageExtensions, t) { + return fmt.Errorf("fileTypes: %s is not an image type (mode: image allows %s)", t, strings.Join(attach.DefaultImageExtensions, ", ")) + } + types[i] = t + } + field.FileTypes = types + } + if node, ok := values["mimeTypes"]; ok { + types, err := tokenList(node) + if err != nil { + return fmt.Errorf("mimeTypes: %w", err) + } + for i, t := range types { + t = strings.ToLower(t) + if !mimeTypePattern.MatchString(t) { + return fmt.Errorf("mimeTypes: %q is not a MIME type or extension", types[i]) + } + types[i] = t + } + field.MimeTypes = types + } + if node, ok := values["maxFilesize"]; ok { + mb, err := nodeNumber(node) + if err != nil || mb <= 0 || math.IsInf(mb, 0) || math.IsNaN(mb) { + return fmt.Errorf("maxFilesize %q must be a positive number of megabytes", nodeText(node)) + } + field.MaxFilesize = &mb + } + if node, ok := values["maxFiles"]; ok { + n, err := nodeInt(node) + if err != nil || n < 1 { + return fmt.Errorf("maxFiles %q must be a positive integer", nodeText(node)) + } + v := int(n) + field.MaxFiles = &v + } + for _, key := range []string{"imageWidth", "imageHeight"} { + node, ok := values[key] + if !ok { + continue + } + n, err := nodeInt(node) + if err != nil || n < 1 || n > maxImageEdge { + return fmt.Errorf("%s %q must be an integer from 1 to %d", key, nodeText(node), maxImageEdge) + } + v := int(n) + if key == "imageWidth" { + field.ImageWidth = &v + } else { + field.ImageHeight = &v + } + } + if node, ok := values["thumbOptions"]; ok { + opts, err := compileThumbOptions(node) + if err != nil { + return fmt.Errorf("thumbOptions: %w", err) + } + field.ThumbOptions = opts + } + if node, ok := values["useCaption"]; ok { + v, err := nodeBool(node) + if err != nil { + return fmt.Errorf("useCaption: %w", err) + } + field.UseCaption = v + } + if node, ok := values["prompt"]; ok { + prompt, err := nodeString(node) + if err != nil { + return fmt.Errorf("prompt: %w", err) + } + field.Prompt = prompt + } + return nil +} + +func compileThumbOptions(node ast.Node) (*ThumbOptions, error) { + mapping, ok := node.(*ast.MappingNode) + if !ok { + return nil, fmt.Errorf("must be a mapping") + } + opts := &ThumbOptions{} + for _, entry := range mapping.Values { + key, err := nodeString(unwrapNode(entry.Key)) + if err != nil { + return nil, err + } + if key != "mode" { + return nil, fmt.Errorf("unknown field %s (only mode is supported)", key) + } + mode, err := nodeString(unwrapNode(entry.Value)) + if _, known := thumbModes[mode]; err != nil || !known { + return nil, fmt.Errorf("mode %q must be auto, exact, crop or fit", nodeText(entry.Value)) + } + opts.Mode = mode + } + if opts.Mode == "" { + return nil, fmt.Errorf("mode is required") + } + return opts, nil +} + +// tokenList reads a comma- or pipe-separated string or a YAML list of strings. +func tokenList(node ast.Node) ([]string, error) { + var raw []string + switch n := unwrapNode(node).(type) { + case *ast.StringNode: + raw = strings.FieldsFunc(n.Value, func(r rune) bool { return r == ',' || r == '|' }) + case *ast.SequenceNode: + for _, item := range sequenceValues(n) { + text, err := nodeString(unwrapNode(item)) + if err != nil { + return nil, fmt.Errorf("want a list of strings") + } + raw = append(raw, text) + } + default: + return nil, fmt.Errorf("want a string or a list") + } + out := make([]string, 0, len(raw)) + for _, item := range raw { + if item = strings.TrimSpace(item); item != "" { + out = append(out, item) + } + } + if len(out) == 0 { + return nil, fmt.Errorf("list is empty") + } + return out, nil +} + +func nodeInt(node ast.Node) (int64, error) { + n, ok := unwrapNode(node).(*ast.IntegerNode) + if !ok { + return 0, fmt.Errorf("want an integer") + } + switch v := n.Value.(type) { + case int64: + return v, nil + case uint64: + if v > math.MaxInt32 { + return 0, fmt.Errorf("integer out of range") + } + return int64(v), nil + case int: + return int64(v), nil + default: + return 0, fmt.Errorf("want an integer") + } +} + +func nodeNumber(node ast.Node) (float64, error) { + switch n := unwrapNode(node).(type) { + case *ast.IntegerNode: + i, err := nodeInt(n) + return float64(i), err + case *ast.FloatNode: + return n.Value, nil + default: + return 0, fmt.Errorf("want a number") + } +} + +// compiledFile is one fileupload field bound to its model's attach.Relation +// at boot (D-06). +type compiledFile struct { + name string + field *FormField + relation attach.Relation + limits attach.Limits + maxFiles int + required bool + thumbW int + thumbH int + thumbMode string + // maxBytes is maxFilesize in bytes, 0 when not set. + maxBytes int64 +} + +// compileFileFields binds every fileupload field of the controller's form to +// an attach.Relation the record model declares. The model must implement +// attach.Owner and attach.HasRelations. +func compileFileFields(pluginID string, cc *CompiledController) error { + if cc == nil || cc.Form == nil { + return nil + } + ctlID := controllerID(cc) + var record any + for i := range cc.Form.Fields { + field := &cc.Form.Fields[i] + if field.Type != "fileupload" { + continue + } + fail := func(format string, args ...any) error { + return bootErr(pluginID, ctlID, cc.Form.fieldsPath, fmt.Errorf("field %s: "+format, append([]any{field.Name}, args...)...)) + } + if record == nil { + src, ok := cc.Controller.(pact.AdminRecordSource) + if !ok || src == nil || src.NewRecord() == nil { + return fail("type fileupload needs a controller with NewRecord") + } + record = src.NewRecord() + } + if _, ok := record.(attach.Owner); !ok { + return fail("type fileupload needs a model implementing attach.Owner (MorphName)") + } + declared, ok := record.(attach.HasRelations) + if !ok { + return fail("type fileupload needs a model implementing attach.HasRelations (AttachRelations)") + } + var rel *attach.Relation + for _, candidate := range declared.AttachRelations() { + if candidate.Name == field.Name { + c := candidate + rel = &c + break + } + } + if rel == nil { + return fail("is not an attachment relation the model declares in AttachRelations") + } + if field.MaxFiles != nil && !rel.Many { + return fail("maxFiles is only valid on an attachMany relation") + } + field.Multiple = rel.Many + field.Protected = !rel.Public + cf := &compiledFile{ + name: field.Name, + field: field, + relation: *rel, + required: field.Required, + thumbW: defaultThumbEdge, + thumbH: defaultThumbEdge, + thumbMode: "crop", + limits: attach.Limits{ + Extensions: append([]string(nil), field.FileTypes...), + MIMETypes: append([]string(nil), field.MimeTypes...), + Image: field.Mode == "image", + }, + } + if field.MaxFiles != nil { + cf.maxFiles = *field.MaxFiles + } + if field.MaxFilesize != nil { + cf.maxBytes = int64(math.Ceil(*field.MaxFilesize * megabyte)) + cf.limits.MaxBytes = cf.maxBytes + } + switch { + case field.ImageWidth != nil && field.ImageHeight != nil: + cf.thumbW, cf.thumbH = *field.ImageWidth, *field.ImageHeight + case field.ImageWidth != nil: + cf.thumbW, cf.thumbH = *field.ImageWidth, *field.ImageWidth + case field.ImageHeight != nil: + cf.thumbW, cf.thumbH = *field.ImageHeight, *field.ImageHeight + } + if field.ThumbOptions != nil && field.ThumbOptions.Mode != "" { + cf.thumbMode = field.ThumbOptions.Mode + } + if cc.files == nil { + cc.files = map[string]*compiledFile{} + } + cc.files[field.Name] = cf + } + return nil +} + +// checkFileLimits refuses a maxFilesize above http.body_limits.upload_bytes, +// as WinterCMS refuses one above upload_max_filesize. +func checkFileLimits(reg *Registry, uploadBytes int64) error { + if reg == nil || uploadBytes <= 0 { + return nil + } + for _, cc := range reg.byID { + for _, cf := range cc.files { + if cf.maxBytes > uploadBytes { + path := "" + if cc.Form != nil { + path = cc.Form.fieldsPath + } + return bootErr(cc.PluginID, controllerID(cc), path, fmt.Errorf("field %s: maxFilesize exceeds http.body_limits.upload_bytes", cf.name)) + } + } + } + return nil +} + +// configBytes reads a whole positive byte count with surf's +// http.body_limits rules. An absent key (or no config) is 0. +func configBytes(app *backpack.App, key string) (int64, error) { + if app == nil || app.Config == nil { + return 0, nil + } + raw, ok := app.Config.Lookup(key) + if !ok || raw == nil { + return 0, nil + } + var n int64 + switch v := raw.(type) { + case int: + n = int64(v) + case int64: + n = v + case uint64: + if v > math.MaxInt64 { + return 0, fmt.Errorf("cabana: config %s out of range", key) + } + n = int64(v) + case float64: + if v != math.Trunc(v) || v > 9007199254740992 { + return 0, fmt.Errorf("cabana: config %s must be a whole number", key) + } + n = int64(v) + default: + return 0, fmt.Errorf("cabana: config %s must be numeric, got %T", key, raw) + } + if n < 1 { + return 0, fmt.Errorf("cabana: config %s must be >= 1", key) + } + return n, nil +} + +// FileItem is one file of a fileupload field as the admin API lists it. +// Pending is true for an upload bound to the request's session key that the +// record's next save attaches. URL and ThumbURL are set only for a public +// relation; a protected file is read through the admin download and thumb +// routes. +type FileItem struct { + ID uint `json:"id"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + ContentType string `json:"content_type"` + Title string `json:"title"` + Description string `json:"description"` + SortOrder int `json:"sort_order"` + Pending bool `json:"pending"` + URL string `json:"url,omitempty"` + ThumbURL string `json:"thumb_url,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// fileScope is a resolved file route: one fileupload field of one owner +// record (ownerID 0 is the record being created in this session) and, when +// the request carries a session key, the admin's deferred-binding key. +type fileScope struct { + cc *CompiledController + file *compiledFile + ownerID uint + owner any + morph string + key lagoon.DeferredKey + hasKey bool +} + +func (sc *fileScope) ownerText() string { return uitoa(sc.ownerID) } + +// parentFileScope resolves the field, the operation context and the owner of +// a file route inside tx. An unknown field, a field its context hides, an +// unsaved owner (id 0) without a session key and an owner outside +// FormExtendQuery are all recordNotFound. +func parentFileScope(ctx context.Context, tx *gorm.DB, r *http.Request, cc *CompiledController) (*fileScope, error) { + cf := cc.files[r.PathValue("field")] + if cf == nil { + return nil, recordNotFound{} + } + id, err := pathID(r) + if err != nil { + return nil, err + } + key, hasKey, err := sessionKeyFrom(r) + if err != nil { + return nil, err + } + op := "update" + if id == 0 { + op = "create" + if !hasKey || !cc.operationDeclared("create") { + return nil, recordNotFound{} + } + } + if !contextAllows(cc, cf.name, op) { + return nil, recordNotFound{} + } + sc := &fileScope{cc: cc, file: cf, ownerID: id} + proto, err := newWritableModel(cc) + if err != nil { + return nil, err + } + sc.morph, err = lagoon.MorphType(tx, proto) + if err != nil { + return nil, lifecycleFailure(cc, err) + } + if hasKey { + principal, _ := bouncer.User(ctx) + if principal == nil || principal.ID == 0 { + return nil, recordNotFound{} + } + sc.key = lagoon.DeferredKey{SessionKey: key, AdminID: principal.ID, MasterType: sc.morph} + sc.hasKey = true + } + if id > 0 { + if err := loadRecord(ctx, tx, cc, proto, castPK(proto, id)); err != nil { + return nil, err + } + sc.owner = proto + } + return sc, nil +} + +// visibleFiles is WinterCMS's withDeferred for one file field: the files +// attached to the owner minus the session's pending removals, plus the +// session's pending uploads, in sort_order then id order. +func (sc *fileScope) visibleFiles(tx *gorm.DB) ([]attach.File, map[uint]bool, error) { + q := tx.Session(&gorm.Session{NewDB: true}).Model(&attach.File{}) + var attached *gorm.DB + if sc.ownerID > 0 { + attached = tx.Session(&gorm.Session{NewDB: true}). + Where("attachment_type = ? AND attachment_id = ? AND field = ?", sc.morph, sc.ownerText(), sc.file.name) + if sc.hasKey { + attached = attached.Where("CAST(id AS TEXT) NOT IN (?)", lagoon.DeferredSlaves(tx, sc.key, sc.file.name, lagoon.DeferredFileType, false)) + } + } + var pending *gorm.DB + if sc.hasKey { + pending = tx.Session(&gorm.Session{NewDB: true}). + Where("(attachment_id IS NULL OR attachment_id = '') AND CAST(id AS TEXT) IN (?)", lagoon.DeferredSlaves(tx, sc.key, sc.file.name, lagoon.DeferredFileType, true)) + } + switch { + case attached != nil && pending != nil: + q = q.Where(attached).Or(pending) + case attached != nil: + q = q.Where(attached) + case pending != nil: + q = q.Where(pending) + default: + return nil, nil, nil + } + var files []attach.File + if err := q.Order("sort_order").Order("id").Find(&files).Error; err != nil { + return nil, nil, err + } + isPending := map[uint]bool{} + for _, f := range files { + if f.AttachmentID == "" { + isPending[f.ID] = true + } + } + return files, isPending, nil +} + +// fileItem projects a stored file. Public URLs are emitted only for a +// public relation (never for a protected file, D-10). +func fileItem(ctx context.Context, bucket *blob.Bucket, cf *compiledFile, f *attach.File, pending bool) FileItem { + item := FileItem{ + ID: f.ID, + FileName: f.FileName, + FileSize: f.FileSize, + ContentType: f.ContentType, + SortOrder: f.SortOrder, + Pending: pending, + CreatedAt: f.CreatedAt, + } + if f.Title != nil { + item.Title = *f.Title + } + if f.Description != nil { + item.Description = *f.Description + } + if !cf.relation.Public || !f.Public() { + return item + } + item.URL = f.URL() + if bucket != nil && slices.Contains(attach.AllowedImageMIMEs, f.ContentType) { + thumb, err := f.Thumb(ctx, bucket, cf.thumbW, cf.thumbH, cf.thumbMode) + if err != nil { + slog.Default().WarnContext(ctx, "cabana: file thumbnail failed", "file_id", f.ID, "error", err) + } else { + item.ThumbURL = thumb + } + } + return item +} + +// fileList serves GET .../{id}/files/{field} (dispatched by nestedGet). +func (s *service) fileList(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + db, err := s.db() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + bucket := s.bucket() + var items []FileItem + err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { + ctx = withTx(ctx, tx) + sc, err := parentFileScope(ctx, tx, r, cc) + if err != nil { + return err + } + files, pending, err := sc.visibleFiles(tx) + if err != nil { + return lifecycleFailure(cc, err) + } + items = make([]FileItem, 0, len(files)) + for i := range files { + items = append(items, fileItem(ctx, bucket, sc.file, &files[i], pending[files[i].ID])) + } + return nil + }) + if err != nil { + writeCRUDError(w, err) + return + } + WriteData(w, http.StatusOK, items, nil) + }) +} + +// fileUpload serves POST .../{id}/files/{field}: it stores one multipart +// file_data part with attach.Store and binds it to the session key (D-03). +// The record's next save attaches it. +func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + cf := cc.files[r.PathValue("field")] + if cf == nil { + writeNotFound(w, r) + return + } + if _, ok, err := sessionKeyFrom(r); err != nil || !ok { + if err == nil { + err = &ValidationError{Details: map[string]any{"session_key": []string{"The session key field is required."}}} + } + writeCRUDError(w, err) + return + } + db, err := s.db() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + bucket := s.bucket() + if bucket == nil { + slog.Default().ErrorContext(r.Context(), "cabana: file upload without a storage bucket", "controller", controllerID(cc)) + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))} + r.Body = io.NopCloser(body) + mr, err := r.MultipartReader() + if err != nil { + writeCRUDError(w, invalidBody()) + return + } + part, err := mr.NextPart() + if err != nil { + s.writeFileError(w, r, cf, body, err) + return + } + if part.FormName() != "file_data" || strings.TrimSpace(part.FileName()) == "" { + writeCRUDError(w, invalidBody()) + return + } + var stored *attach.File + var item FileItem + err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { + ctx = withTx(ctx, tx) + sc, err := parentFileScope(ctx, tx, r, cc) + if err != nil { + return err + } + if cf.relation.Many && cf.maxFiles > 0 { + files, _, err := sc.visibleFiles(tx) + if err != nil { + return lifecycleFailure(cc, err) + } + if len(files) >= cf.maxFiles { + return &ValidationError{Details: fieldDetail(cf.name, fileMessage(ctx, s.translator(), "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)}))} + } + } + f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: cf.relation.Public}, cf.limits) + if err != nil { + return err + } + stored = f + // Exactly one part: anything after file_data is refused. + if _, err := mr.NextPart(); !errors.Is(err, io.EOF) { + if err == nil { + return invalidBody() + } + return err + } + if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), nil); err != nil { + return lifecycleFailure(cc, err) + } + item = fileItem(ctx, bucket, cf, f, true) + return nil + }) + if err != nil { + // attach.Store wrote the blob before the row; a rolled-back + // transaction leaves it to this caller (12.2-01). + if stored != nil { + _ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored)) + } + s.writeFileError(w, r, cf, body, err) + return + } + WriteData(w, http.StatusCreated, item, nil) + }) +} + +// uploadCap is the request body cap of an upload: the smaller of +// http.body_limits.upload_bytes and maxFilesize plus the multipart framing. +func (s *service) uploadCap(cf *compiledFile) int64 { + limit := int64(0) + if s != nil && s.uploadBytes > 0 { + limit = s.uploadBytes + } + if cf != nil && cf.maxBytes > 0 { + if field := cf.maxBytes + multipartOverhead; limit == 0 || field < limit { + limit = field + } + } + if limit == 0 { + limit = defaultUploadCap + } + return limit +} + +// writeFileError maps file route failures: a body past the cap is 413 +// payload_too_large, an attach.Store refusal is a 422 on the field, and a +// malformed multipart body is a 422 on body. +func (s *service) writeFileError(w http.ResponseWriter, r *http.Request, cf *compiledFile, body *bodyReader, err error) { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) || (body != nil && errors.As(body.err, &tooBig)) { + WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge) + return + } + ctx, tr := r.Context(), s.translator() + var detail string + switch { + 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}) + case errors.Is(err, attach.ErrFileType): + types := cf.limits.Extensions + if len(types) == 0 { + types = attach.DefaultFileExtensions + if cf.limits.Image { + types = attach.DefaultImageExtensions + } + } + detail = fileMessage(ctx, tr, "mimes", cf.name, map[string]string{"values": strings.Join(types, ", ")}) + case errors.Is(err, attach.ErrMIMEType): + detail = fileMessage(ctx, tr, "mimetypes", cf.name, map[string]string{"values": strings.Join(cf.limits.MIMETypes, ", ")}) + case errors.Is(err, attach.ErrNotImage): + detail = fileMessage(ctx, tr, "image", cf.name, nil) + } + if detail != "" { + writeCRUDError(w, &ValidationError{Details: fieldDetail(cf.name, detail)}) + return + } + if body != nil && body.err != nil { + writeCRUDError(w, invalidBody()) + return + } + logFileFailure(r, err) + writeCRUDError(w, err) +} + +// logFileFailure logs an unexpected file route error (a storage or +// database failure) before it becomes the generic 500 body. +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) { + 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 { + r io.Reader + err error +} + +func (b *bodyReader) Read(p []byte) (int, error) { + n, err := b.r.Read(p) + if err != nil && !errors.Is(err, io.EOF) && b.err == nil { + b.err = err + } + return n, err +} + +func invalidBody() error { + return &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}} +} + +func fieldDetail(field, message string) map[string]any { + return map[string]any{field: []string{message}} +} + +// fileMessage is a lagoon::validation line for a file field, with Laravel's +// English text when no translator has the key. +func fileMessage(ctx context.Context, tr *phrasebook.Translator, rule, field string, params map[string]string) string { + if params == nil { + params = map[string]string{} + } + attr := strings.ReplaceAll(field, "_", " ") + params["attribute"] = attr + key := "lagoon::validation." + rule + if tr != nil && tr.Has(key) { + if s := tr.Get(ctx, key, params); s != "" && s != key { + return s + } + } + switch rule { + case "max.file": + return "The " + attr + " may not be greater than " + params["max"] + " kilobytes." + case "max.array": + return "The " + attr + " may not have more than " + params["max"] + " items." + case "mimes", "mimetypes": + return "The " + attr + " must be a file of type: " + params["values"] + "." + case "image": + return "The " + attr + " must be an image." + case "required": + return "The " + attr + " field is required." + } + return "The " + attr + " is invalid." +} + +// bucket is the application's attachment bucket (attach.Publish), or nil. +func (s *service) bucket() *blob.Bucket { + if s == nil || s.app == nil { + return nil + } + b, ok := s.app.Lookup[*blob.Bucket]() + if !ok { + return nil + } + return b +} + +// lockFile loads one system_files row FOR UPDATE, or nil when it is gone. +func lockFile(ctx context.Context, tx *gorm.DB, id uint) (*attach.File, error) { + var f attach.File + err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", id).Take(&f).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &f, nil +} diff --git a/modules/cabana/fileupload_smoke_test.go b/modules/cabana/fileupload_smoke_test.go new file mode 100644 index 0000000..2267e5b --- /dev/null +++ b/modules/cabana/fileupload_smoke_test.go @@ -0,0 +1,118 @@ +package cabana_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/lagoon" +) + +func fileList(t *testing.T, rec *httptest.ResponseRecorder) []cabana.FileItem { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("file list status=%d body=%s", rec.Code, rec.Body.String()) + } + var body cabana.Envelope[[]cabana.FileItem] + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("file list: %v\n%s", err, rec.Body.String()) + } + return body.Data +} + +func (e *conformEnv) listFiles(t *testing.T, id uint, field, key string) []cabana.FileItem { + t.Helper() + headers := map[string]string{} + if key != "" { + headers[cabana.SessionKeyHeader] = key + } + return fileList(t, e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/files/%s", id, field), nil, "", headers)) +} + +func (e *conformEnv) loginAs(t *testing.T, login string) { + t.Helper() + e.login = login + if rec := e.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK { + t.Fatalf("login %s status=%d body=%s", login, rec.Code, rec.Body.String()) + } +} + +func (e *conformEnv) bindingCount(t *testing.T, key string) int64 { + t.Helper() + var n int64 + if err := e.db.Model(&lagoon.DeferredBinding{}).Where("session_key = ?", key).Count(&n).Error; err != nil { + t.Fatal(err) + } + return n +} + +// TestFileuploadSmokeCreateCommit uploads an image to the create form (id 0) +// and saves the record with the same session key: the file is attached to +// the new record and the binding is gone. +func TestFileuploadSmokeCreateCommit(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()) + } + if got := env.listFiles(t, 0, "photos", key); len(got) != 1 || !got[0].Pending { + t.Fatalf("pending list = %#v", got) + } + payload, _ := json.Marshal(map[string]any{"name": "smoke-" + env.stamp}) + created := env.sendWith(t, http.MethodPost, "/acme/conform/gadgets", payload, "application/json", map[string]string{cabana.SessionKeyHeader: key}) + if created.Code != http.StatusCreated { + t.Fatalf("create status=%d body=%s", created.Code, created.Body.String()) + } + id := dataID(t, created.Body.Bytes()) + got := env.listFiles(t, id, "photos", "") + if len(got) != 1 || got[0].Pending || got[0].FileName != "photo.png" || got[0].URL == "" { + t.Fatalf("attached list = %#v", got) + } + if n := env.bindingCount(t, key); n != 0 { + t.Fatalf("deferred_bindings rows for the key after save = %d", n) + } + // Without the key, the unsaved form shows nothing: id 0 is 404. + if rec := env.sendWith(t, http.MethodGet, "/acme/conform/gadgets/0/files/photos", nil, "", nil); rec.Code != http.StatusNotFound { + t.Fatalf("id 0 without key status=%d", rec.Code) + } + // A malformed key is a 422 on session_key. + if rec := env.sendWith(t, http.MethodGet, "/acme/conform/gadgets/0/files/photos", nil, "", map[string]string{cabana.SessionKeyHeader: "short"}); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("malformed key status=%d", rec.Code) + } +} + +// TestFileuploadSmokeForeignAdmin replays one admin's session key as another +// admin: the pending upload is neither listed nor committed. +func TestFileuploadSmokeForeignAdmin(t *testing.T) { + env := newConformEnv(t) + env.loginAs(t, env.login) + key := newSessionKey(t) + if up := env.upload(t, 0, "photos", "photo.png", conformPNG(t), key); up.Code != http.StatusCreated { + t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String()) + } + + other := *env + login := "other-" + env.stamp + insertAdmin(t, env.db, login, login+"@example.test", adminTestPassword, true, false) + other.loginAs(t, login) + if got := other.listFiles(t, 0, "photos", key); len(got) != 0 { + t.Fatalf("foreign admin sees %#v", got) + } + payload, _ := json.Marshal(map[string]any{"name": "foreign-" + env.stamp}) + created := other.sendWith(t, http.MethodPost, "/acme/conform/gadgets", payload, "application/json", map[string]string{cabana.SessionKeyHeader: key}) + if created.Code != http.StatusCreated { + t.Fatalf("create status=%d body=%s", created.Code, created.Body.String()) + } + if got := other.listFiles(t, dataID(t, created.Body.Bytes()), "photos", ""); len(got) != 0 { + t.Fatalf("foreign save attached %#v", got) + } + if n := env.bindingCount(t, key); n != 1 { + t.Fatalf("owner's binding rows = %d, want 1", n) + } +} diff --git a/modules/cabana/form_schema.go b/modules/cabana/form_schema.go index afca258..97220ae 100644 --- a/modules/cabana/form_schema.go +++ b/modules/cabana/form_schema.go @@ -23,7 +23,7 @@ var ( formFieldTypes = map[string]struct{}{ "text": {}, "textarea": {}, "number": {}, "checkbox": {}, "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, - "widget": {}, "partial": {}, + "widget": {}, "partial": {}, "fileupload": {}, } formSpans = map[string]struct{}{ "left": {}, "right": {}, "full": {}, "auto": {}, "row": {}, @@ -36,6 +36,9 @@ var ( "tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {}, "nameFrom": {}, "emptyOption": {}, "options": {}, "relation": {}, "widget": {}, "action": {}, "fill": {}, "path": {}, + "mode": {}, "fileTypes": {}, "mimeTypes": {}, "maxFilesize": {}, + "maxFiles": {}, "imageWidth": {}, "imageHeight": {}, + "thumbOptions": {}, "useCaption": {}, "prompt": {}, } // widgetKeys are valid only on `type: widget` (D-06). widgetKeys = []string{"widget", "action", "fill"} @@ -145,7 +148,10 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr field.Tab = translateKey(ctx, tr, src.Tab) field.EmptyOption = translateKey(ctx, tr, src.EmptyOption) field.ActionLabel = translateKey(ctx, tr, src.ActionLabel) + field.Prompt = translateKey(ctx, tr, src.Prompt) field.Fill = append([]string(nil), src.Fill...) + field.FileTypes = append([]string(nil), src.FileTypes...) + field.MimeTypes = append([]string(nil), src.MimeTypes...) options, err := localizeOptions(ctx, tr, src, provider) if err != nil { return nil, err @@ -422,6 +428,9 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) { if err := compilePartialPath(typ, values, &field); err != nil { return FormField{}, err } + if err := compileFileuploadKeys(typ, values, &field); err != nil { + return FormField{}, err + } if node, ok := values["required"]; ok { field.Required, err = nodeBool(node) if err != nil { diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 6fbbbb1..d1e2c31 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -48,6 +48,11 @@ type service struct { // insecureCookie drops Secure from the admin cookie (backend.cookie_secure // false, development only); the zero value keeps the cookie Secure. insecureCookie bool + // uploadBytes and defaultBytes are http.body_limits.upload_bytes and + // default_bytes (0 when not configured): the file routes cap their own + // bodies, since surf applies no body limit to raw routes. + uploadBytes int64 + defaultBytes int64 } // adminPrefix returns the mount path; a zero service uses the default. @@ -92,6 +97,17 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { if err != nil { return nil, err } + uploadBytes, err := configBytes(app, "http.body_limits.upload_bytes") + if err != nil { + return nil, err + } + defaultBytes, err := configBytes(app, "http.body_limits.default_bytes") + if err != nil { + return nil, err + } + if err := checkFileLimits(reg, uploadBytes); err != nil { + return nil, err + } if err := compileContributions(reg, plugins); err != nil { return nil, err } @@ -147,6 +163,8 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { prefix: prefix, insecureCookie: !secureCookie, + uploadBytes: uploadBytes, + defaultBytes: defaultBytes, } spa, err := boardwalk.Handler(prefix, http.HandlerFunc(writeNotFound)) if err != nil { @@ -257,6 +275,10 @@ func (s *service) mount(r pact.Router) { constrainRelation(g) g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink)) constrainRelation(g) + // File routes of `type: fileupload` fields (D-09). {id} 0 is the + // record being created in the X-Session-Key session. + g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload)) + constrainFile(g) }) // The SPA shell: public, no guard. ServeMux prefers every API pattern // above over the {path...} wildcard. @@ -282,6 +304,11 @@ func constrainRelation(g pact.Router) { g.Where("name", "[A-Za-z_][A-Za-z0-9_]*") } +func constrainFile(g pact.Router) { + constrainController(g) + g.Where("field", "[A-Za-z_][A-Za-z0-9_]*") +} + func constrainNested(g pact.Router) { constrainController(g) g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*") @@ -291,6 +318,7 @@ func constrainNested(g pact.Router) { // nestedGet serves the logical routes // // GET /{vendor}/{plugin}/{controller}/{id}/relations/{name} +// GET /{vendor}/{plugin}/{controller}/{id}/files/{field} // GET /{vendor}/{plugin}/{controller}/fields/{field}/options // GET /{vendor}/{plugin}/{controller}/filters/{scope}/options // @@ -307,6 +335,9 @@ func (s *service) nestedGet(w http.ResponseWriter, r *http.Request) { s.filterOptions(w, r) case segment == "relations": s.relationLinked(w, r) + case segment == "files": + r.SetPathValue("field", name) + s.fileList(w, r) default: writeNotFound(w, r) } @@ -635,6 +666,11 @@ func (s *service) create(w http.ResponseWriter, r *http.Request) { if !s.operationDeclared(w, r, cc, "create") { return } + key, _, err := sessionKeyFrom(r) + if err != nil { + writeCRUDError(w, err) + return + } body, err := decodeObject(r) if err != nil { writeCRUDError(w, err) @@ -645,7 +681,7 @@ func (s *service) create(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } - rec, err := svc.CreateRecord(r.Context(), cc, RecordInput{Body: body}) + rec, err := svc.CreateRecord(r.Context(), cc, RecordInput{Body: body, SessionKey: key}) if err != nil { writeCRUDError(w, err) return @@ -664,6 +700,11 @@ func (s *service) update(w http.ResponseWriter, r *http.Request) { writeCRUDError(w, err) return } + key, _, err := sessionKeyFrom(r) + if err != nil { + writeCRUDError(w, err) + return + } body, err := decodeObject(r) if err != nil { writeCRUDError(w, err) @@ -674,7 +715,7 @@ func (s *service) update(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } - rec, err := svc.UpdateRecord(r.Context(), cc, id, RecordInput{Body: body}) + rec, err := svc.UpdateRecord(r.Context(), cc, id, RecordInput{Body: body, SessionKey: key}) if err != nil { writeCRUDError(w, err) return diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index 99b244e..c4d1a2a 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -3,9 +3,15 @@ package cabana_test import ( "bytes" "context" + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" + "image" + "image/color" + "image/png" "io/fs" + "mime/multipart" "net/http" "net/http/httptest" "os" @@ -20,10 +26,13 @@ import ( "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/surf" + "gocloud.dev/blob" + "gocloud.dev/blob/memblob" "gorm.io/gorm" ) @@ -112,6 +121,17 @@ func TestPhase10OpenAPIConformance(t *testing.T) { {"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]]()}, + {"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]]()}, + {"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] + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body.Data) != 1 || !body.Data[0].Pending || body.Data[0].URL == "" || body.Data[0].ThumbURL == "" { + t.Fatalf("pending file list = %s (%v)", rec.Body.String(), err) + } + return rec + }, into[cabana.Envelope[[]cabana.FileItem]]()}, {"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()) @@ -255,13 +275,78 @@ func conformSpec(t *testing.T) conformSpecDoc { } type conformEnv struct { - h http.Handler - login string - token string - stamp string - groupID uint - memberID uint - gadgetID uint + h http.Handler + db *gorm.DB + bucket *blob.Bucket + login string + token string + stamp string + sessionKey string + groupID uint + memberID uint + gadgetID uint +} + +// sendWith sends a raw body with extra headers through the assembled router. +func (e *conformEnv) sendWith(t *testing.T, method, rel string, body []byte, contentType string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, adminAPI(rel), bytes.NewReader(body)) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Authorization", "Bearer "+e.token) + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + return rec +} + +// upload posts one multipart file_data part to a gadget's file field. +func (e *conformEnv) upload(t *testing.T, id uint, field, name string, data []byte, key string) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile("file_data", name) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(data); err != nil { + t.Fatal(err) + } + if err := mw.Close(); err != nil { + t.Fatal(err) + } + headers := map[string]string{} + if key != "" { + headers[cabana.SessionKeyHeader] = key + } + return e.sendWith(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/files/%s", id, field), buf.Bytes(), mw.FormDataContentType(), headers) +} + +// conformPNG is a small valid PNG. +func conformPNG(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 4, 3)) + for x := 0; x < 4; x++ { + img.Set(x, 1, color.RGBA{R: 200, A: 255}) + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// newSessionKey is a random form session key as the SPA makes one. +func newSessionKey(t *testing.T) string { + t.Helper() + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(raw) } func (e *conformEnv) send(t *testing.T, method, rel string, body any, auth bool) *httptest.ResponseRecorder { @@ -340,6 +425,14 @@ func newConformEnv(t *testing.T) *conformEnv { if err := gdb.AutoMigrate(models...); err != nil { t.Fatal(err) } + // Gadget ids restart with the recreated table: drop the files and + // bindings an earlier run left for them. + if err := gdb.Exec(`DELETE FROM system_files WHERE attachment_type = 'acme.conform.gadget' OR attachment_id IS NULL OR attachment_id = ''`).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Exec(`DELETE FROM deferred_bindings WHERE master_type = 'acme.conform.gadget'`).Error; err != nil { + t.Fatal(err) + } stamp := fmt.Sprintf("c%d", time.Now().UnixNano()) group := conformGroup{Title: "Group " + stamp} member := conformMember{Email: "member-" + stamp + "@example.test"} @@ -369,6 +462,11 @@ func newConformEnv(t *testing.T) *conformEnv { if err := lagoon.Publish(app, adminSQL, gdb); err != nil { t.Fatal(err) } + bucket := memblob.OpenBucket(nil) + t.Cleanup(func() { _ = bucket.Close() }) + if err := attach.Publish(app, bucket); err != nil { + t.Fatal(err) + } plugins := []party.Plugin{conformPlugin{stamp: stamp}} if err := phrasebook.Activate(app, plugins); err != nil { t.Fatal(err) @@ -377,7 +475,7 @@ func newConformEnv(t *testing.T) *conformEnv { if err != nil { t.Fatal(err) } - return &conformEnv{h: h, login: login, stamp: stamp, groupID: group.ID, memberID: member.ID} + return &conformEnv{h: h, db: gdb, bucket: bucket, login: login, stamp: stamp, sessionKey: newSessionKey(t), groupID: group.ID, memberID: member.ID} } type conformGadget struct { @@ -391,6 +489,10 @@ 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}} +} func (conformGadget) Fillable() []string { return []string{"name", "active"} } func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} } func (conformGadget) FilterScopes() []string { return []string{"filterByGroup"} } @@ -623,6 +725,14 @@ update: label: Summary type: partial path: summary + photos: + label: Photos + type: fileupload + mode: image + maxFiles: 5 + maxFilesize: 0.5 + thumbOptions: + mode: crop `), "models/settings/fields.yaml": file(`fields: enabled: diff --git a/modules/cabana/phase10_coverage_test.go b/modules/cabana/phase10_coverage_test.go index 4925d74..7aa8a90 100644 --- a/modules/cabana/phase10_coverage_test.go +++ b/modules/cabana/phase10_coverage_test.go @@ -96,13 +96,14 @@ func TestPhase10Coverage(t *testing.T) { "POST /{vendor}/{plugin}/{controller}/bulk-delete", "POST /{vendor}/{plugin}/{controller}/toolbar/{action}", "POST /{vendor}/{plugin}/{controller}/widgets/{field}", + "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", "PUT /settings/{code}", "PUT /{vendor}/{plugin}/{controller}/{id}", } if strings.Join(unsafe, "\n") != strings.Join(want, "\n") { - t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 11 besides login):\n%s", strings.Join(unsafe, "\n")) + t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 12 besides login):\n%s", strings.Join(unsafe, "\n")) } // The routes added in Phase 10 are safe reads: GET /lang and the shared // nested pattern serving field options, filter options and relation lists. diff --git a/modules/cabana/phase10_csrf_test.go b/modules/cabana/phase10_csrf_test.go index 1643fb7..d7478e6 100644 --- a/modules/cabana/phase10_csrf_test.go +++ b/modules/cabana/phase10_csrf_test.go @@ -67,9 +67,9 @@ func TestPhase10CSRF(t *testing.T) { }) } // refresh, logout, settings put, create, bulk-delete, widget action, - // toolbar action, update, delete, link, unlink - if unsafe != 11 { - t.Fatalf("walked %d state-changing routes, want 11: %v", unsafe, router.order) + // toolbar action, update, delete, link, unlink, file upload + if unsafe != 12 { + t.Fatalf("walked %d state-changing routes, want 12: %v", unsafe, router.order) } loginHandler := router.handlers[login] diff --git a/modules/cabana/registry.go b/modules/cabana/registry.go index 5196b3b..9749922 100644 --- a/modules/cabana/registry.go +++ b/modules/cabana/registry.go @@ -93,6 +93,9 @@ func compileRegistry(items []controllerRef) (*Registry, error) { if err := BindWritableFields(compiled); err != nil { return nil, err } + if err := compileFileFields(item.plugin.ID(), compiled); err != nil { + return nil, err + } if err := compileExtension(item.plugin.ID(), compiled, fsys.AdminFS()); err != nil { return nil, err } diff --git a/modules/cabana/schema_types.go b/modules/cabana/schema_types.go index 41cb318..a7a10db 100644 --- a/modules/cabana/schema_types.go +++ b/modules/cabana/schema_types.go @@ -224,10 +224,39 @@ type FormField struct { // Path names the controller partial of a `type: partial` field: the // template {ConfigDir}/_{path}.htm (D-09). Path string `json:"path,omitempty"` + // Mode is the fileupload mode (image or file, default file). + Mode string `json:"mode,omitempty"` + // FileTypes are the allowed lower-case extensions of a fileupload field. + FileTypes []string `json:"fileTypes,omitempty"` + // MimeTypes are the allowed MIME patterns (or extensions) of a + // fileupload field. + MimeTypes []string `json:"mimeTypes,omitempty"` + // MaxFilesize is the largest accepted file in megabytes. + MaxFilesize *float64 `json:"maxFilesize,omitempty"` + // MaxFiles caps the number of files of an attachMany field. + MaxFiles *int `json:"maxFiles,omitempty"` + // ImageWidth and ImageHeight are the preview size of an image upload. + ImageWidth *int `json:"imageWidth,omitempty"` + ImageHeight *int `json:"imageHeight,omitempty"` + // ThumbOptions carries the preview thumbnail mode. + ThumbOptions *ThumbOptions `json:"thumbOptions,omitempty"` + // UseCaption lets the admin edit each file's title and description. + UseCaption bool `json:"useCaption,omitempty"` + // Prompt is the upload button text, localized per request. + Prompt string `json:"prompt,omitempty"` + // Protected is true for a fileupload field whose relation is not + // public: its files are served only through the admin file routes. + Protected bool `json:"protected,omitempty"` optionsMethod string } +// ThumbOptions is a fileupload field's thumbOptions mapping. Mode is one of +// auto, exact, crop or fit. +type ThumbOptions struct { + Mode string `json:"mode"` +} + // FormOption is one dropdown choice. Value keeps the YAML scalar's JSON type. type FormOption struct { Value jsonScalar `json:"value"` diff --git a/modules/cabana/security_coverage_test.go b/modules/cabana/security_coverage_test.go index ca28164..c422e8d 100644 --- a/modules/cabana/security_coverage_test.go +++ b/modules/cabana/security_coverage_test.go @@ -62,6 +62,8 @@ var phase09Routes = []adminRoute{ {key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"}, {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: "GET /assets/{vendor}/{plugin}/{file...}", public: true, spa: true}, {key: "GET ", public: true, spa: true}, {key: "GET /{path...}", public: true, spa: true}, @@ -286,6 +288,8 @@ func phase09ProtectedCalls() []phase09Call { {"relation-candidates", (*service).relationCandidates}, {"relation-link", (*service).relationLink}, {"relation-unlink", (*service).relationUnlink}, + {"file-list", (*service).fileList}, + {"file-upload", (*service).fileUpload}, {"settings-schema", (*service).settingsSchema}, {"settings-get", (*service).settingsGet}, {"settings-put", (*service).settingsPut}, diff --git a/modules/cabana/settings.go b/modules/cabana/settings.go index 4aeb3dd..524ee44 100644 --- a/modules/cabana/settings.go +++ b/modules/cabana/settings.go @@ -63,7 +63,7 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi } for _, field := range fields { // A settings screen has no admin controller to own actions or view models. - if field.Type == "widget" || field.Type == "partial" { + if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" { return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type) } }