feat(12.2-02): add the fileupload field with deferred uploads committed on save
- type: fileupload compiles the D-08 keys and binds to the model's attach.Relation at boot
- X-Session-Key (cabana.SessionKeyHeader) carries the form session key; RecordInput.SessionKey
- GET and POST .../{id}/files/{field}: list with pending uploads, multipart upload into attach.Store
- the create and update save attaches the session's pending files in its transaction
- swagger2openapi folds formData parameters into a multipart requestBody
- admin OpenAPI, TS types, conformance cases, README and forms docs
This commit is contained in:
@@ -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: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.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 `<prefix>/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
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
115
modules/cabana/deferred.go
Normal file
115
modules/cabana/deferred.go
Normal file
@@ -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 ""
|
||||
}
|
||||
862
modules/cabana/field_file.go
Normal file
862
modules/cabana/field_file.go
Normal file
@@ -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
|
||||
}
|
||||
118
modules/cabana/fileupload_smoke_test.go
Normal file
118
modules/cabana/fileupload_smoke_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user