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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user