- 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
863 lines
26 KiB
Go
863 lines
26 KiB
Go
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
|
|
}
|