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:
Jakub Zych
2026-10-02 18:04:34 +02:00
parent edda803dc1
commit 044e0450ef
22 changed files with 2183 additions and 21 deletions

View 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
}