Files
summercms/modules/cabana/field_file.go
Jakub Zych e54fd257ee feat(12.2-02): add file removal, caption, reorder and protected downloads
- DELETE, PUT and POST reorder under .../{id}/files/{field}, each scoped by one parent query (404 for a foreign file)
- protected download and thumb routes: is_public=false only, nosniff, private no-store, sandbox CSP, inline only for jpeg/png/gif/webp
- the save applies deferred removals, replaces attachOne files and rechecks maxFiles and required
- blobs of deleted files are removed after commit
- swagger2openapi emits binary content for file responses
- admin OpenAPI, TS types, conformance, README and attachments docs
2026-10-02 18:11:56 +02:00

1275 lines
38 KiB
Go

package cabana
import (
"context"
"encoding/json"
"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"
"gocloud.dev/gcerrors"
"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 cf == nil:
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
if errors.As(err, &ve) || errors.As(err, &missing) {
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)
}
// 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
}
// AdminFileCaptionRequest is the body of the file caption route. A nil
// field is left unchanged; unknown keys are refused.
type AdminFileCaptionRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
}
// pathFileID parses {file}; anything but a positive integer is not found.
func pathFileID(r *http.Request) (uint, error) {
n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("file")), 10, 64)
if err != nil || n == 0 {
return 0, recordNotFound{}
}
return uint(n), nil
}
// findFile loads one file of the scope with a single parent-scoped query:
// it must be attached to the scope's owner and field, or be a pending upload
// bound to the scope's session key. Anything else, a file of another record
// included, is recordNotFound (never 403).
func (sc *fileScope) findFile(ctx context.Context, tx *gorm.DB, id uint, lock bool) (*attach.File, error) {
fresh := func() *gorm.DB { return tx.Session(&gorm.Session{NewDB: true, Context: ctx}) }
var group *gorm.DB
if sc.ownerID > 0 {
group = fresh().Where("attachment_type = ? AND attachment_id = ? AND field = ?", sc.morph, sc.ownerText(), sc.file.name)
}
if sc.hasKey {
pending := "(attachment_id IS NULL OR attachment_id = '') AND CAST(id AS TEXT) IN (?)"
slaves := lagoon.DeferredSlaves(tx, sc.key, sc.file.name, lagoon.DeferredFileType, true)
if group == nil {
group = fresh().Where(pending, slaves)
} else {
group = group.Or(pending, slaves)
}
}
if group == nil {
return nil, recordNotFound{}
}
q := fresh().Where("id = ?", id).Where(group)
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
var f attach.File
err := q.Take(&f).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, recordNotFound{}
}
if err != nil {
return nil, err
}
return &f, nil
}
// withFileScope runs fn on the resolved scope of a file route inside one
// transaction and writes the error envelope on failure.
func (s *service) withFileScope(w http.ResponseWriter, r *http.Request, cc *CompiledController, fn func(ctx context.Context, tx *gorm.DB, sc *fileScope) error) bool {
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return false
}
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
}
return fn(ctx, tx, sc)
})
if err != nil {
s.writeFileError(w, r, cc.files[r.PathValue("field")], nil, err)
return false
}
return true
}
// requireSessionKey answers 422 on session_key when the request has no
// valid X-Session-Key.
func requireSessionKey(w http.ResponseWriter, r *http.Request) bool {
_, ok, err := sessionKeyFrom(r)
if err == nil && !ok {
err = &ValidationError{Details: map[string]any{"session_key": []string{"The session key field is required."}}}
}
if err != nil {
writeCRUDError(w, err)
return false
}
return true
}
// deleteBlobsAfterCommit removes a deleted file's blob and thumbnails once
// the surrounding transaction has committed.
func deleteBlobsAfterCommit(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, f attach.File) {
keys := attach.BlobKeys(f)
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
if bucket == nil {
slog.Default().WarnContext(ctx, "cabana: no storage bucket; blobs of a deleted file were kept", "file_id", f.ID)
return
}
if err := attach.DeleteKeys(ctx, bucket, keys); err != nil {
slog.Default().WarnContext(ctx, "cabana: deleting a file's blobs failed", "file_id", f.ID, "error", err)
}
})
}
// fileRemove serves DELETE .../{id}/files/{field}/{file}: it defers the
// removal of an attached file to the next save, or cancels a pending upload
// at once (its row now, its blob after commit).
func (s *service) fileRemove(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if cc.files[r.PathValue("field")] == nil {
writeNotFound(w, r)
return
}
if !requireSessionKey(w, r) {
return
}
fileID, err := pathFileID(r)
if err != nil {
writeCRUDError(w, err)
return
}
bucket := s.bucket()
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
f, err := sc.findFile(ctx, tx, fileID, true)
if err != nil {
return err
}
cancelled, err := lagoon.DeferredUnbind(ctx, tx, sc.key, sc.file.name, lagoon.DeferredFileType, uitoa(f.ID))
if err != nil {
return err
}
if cancelled != nil && f.AttachmentID == "" {
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id = ?", f.ID).Delete(&attach.File{}).Error; err != nil {
return err
}
deleteBlobsAfterCommit(ctx, tx, bucket, *f)
}
return nil
})
if ok {
WriteData(w, http.StatusOK, FileMutationResult{Removed: 1}, nil)
}
})
}
// jsonCap is the body cap of the JSON file routes: http.body_limits.
// default_bytes, or 1 MiB when it is not configured.
func (s *service) jsonCap() int64 {
if s != nil && s.defaultBytes > 0 {
return s.defaultBytes
}
return 1 << 20
}
// decodeStrictBody decodes a capped JSON body into dest with unknown keys
// and trailing data refused.
func (s *service) decodeStrictBody(w http.ResponseWriter, r *http.Request, dest any) error {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap()))
dec.DisallowUnknownFields()
if err := dec.Decode(dest); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return err
}
return invalidBody()
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return invalidBody()
}
return nil
}
// fileUpdate serves PUT .../{id}/files/{field}/{file}: it saves a file's
// title and description at once (WinterCMS's onSaveAttachmentConfig). The
// field must declare useCaption.
func (s *service) fileUpdate(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 !cf.field.UseCaption {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
fileID, err := pathFileID(r)
if err != nil {
writeCRUDError(w, err)
return
}
var in AdminFileCaptionRequest
if err := s.decodeStrictBody(w, r, &in); err != nil {
s.writeFileError(w, r, cf, nil, err)
return
}
bucket := s.bucket()
var item FileItem
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
f, err := sc.findFile(ctx, tx, fileID, true)
if err != nil {
return err
}
updates := map[string]any{}
if in.Title != nil {
updates["title"] = *in.Title
f.Title = in.Title
}
if in.Description != nil {
updates["description"] = *in.Description
f.Description = in.Description
}
if len(updates) > 0 {
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).Where("id = ?", f.ID).Updates(updates).Error; err != nil {
return err
}
}
item = fileItem(ctx, bucket, cf, f, f.AttachmentID == "")
return nil
})
if ok {
WriteData(w, http.StatusOK, item, nil)
}
})
}
// fileReorder serves POST .../{id}/files/{field}/reorder: the submitted ids
// must be exactly the field's visible files, and they receive the visible
// files' existing sort_order values, ascending, in the submitted order.
func (s *service) fileReorder(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 !cf.relation.Many {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
var in AdminIDsRequest
if err := s.decodeStrictBody(w, r, &in); err != nil {
s.writeFileError(w, r, cf, nil, err)
return
}
bucket := s.bucket()
var items []FileItem
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
files, _, err := sc.visibleFiles(tx)
if err != nil {
return err
}
byID := make(map[uint]int, len(files))
orders := make([]int, len(files))
for i, f := range files {
byID[f.ID] = i
orders[i] = f.SortOrder
}
seen := map[uint]bool{}
valid := len(in.IDs) == len(files)
for _, raw := range in.IDs {
id := uint(raw)
if _, known := byID[id]; !known || seen[id] || uint64(id) != raw {
valid = false
break
}
seen[id] = true
}
if !valid {
return &ValidationError{Details: map[string]any{"ids": []string{"The ids field must list every file of the field exactly once."}}}
}
slices.Sort(orders)
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
for i, raw := range in.IDs {
if err := q.Model(&attach.File{}).Where("id = ?", uint(raw)).Update("sort_order", orders[i]).Error; err != nil {
return err
}
}
files, pending, err := sc.visibleFiles(tx)
if err != nil {
return err
}
items = make([]FileItem, 0, len(files))
for i := range files {
items = append(items, fileItem(ctx, bucket, cf, &files[i], pending[files[i].ID]))
}
return nil
})
if ok {
WriteData(w, http.StatusOK, items, nil)
}
})
}
// fileDownload serves GET .../{id}/files/{field}/{file}/download.
func (s *service) fileDownload(w http.ResponseWriter, r *http.Request) {
s.serveProtectedFile(w, r, false)
}
// fileThumb serves GET .../{id}/files/{field}/{file}/thumb.
func (s *service) fileThumb(w http.ResponseWriter, r *http.Request) {
s.serveProtectedFile(w, r, true)
}
// serveProtectedFile streams a protected (is_public false) file or its
// thumbnail (D-10). The file must belong to a record the admin may load
// through FormExtendQuery, or be pending in the admin's own session; a
// public file, a file of another record and a thumbnail of a non-image are
// 404. Only JPEG, PNG, GIF and WebP are served inline; everything else is an
// application/octet-stream attachment. Every response is nosniff, private
// and no-store, under a sandboxing CSP.
func (s *service) serveProtectedFile(w http.ResponseWriter, r *http.Request, thumb bool) {
s.protect(w, r, func(cc *CompiledController) {
cf := cc.files[r.PathValue("field")]
if cf == nil {
writeNotFound(w, r)
return
}
fileID, err := pathFileID(r)
if err != nil {
writeCRUDError(w, err)
return
}
bucket := s.bucket()
if bucket == nil {
slog.Default().ErrorContext(r.Context(), "cabana: protected file route without a storage bucket", "controller", controllerID(cc))
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
var f *attach.File
ok := s.withFileScope(w, r, cc, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
found, err := sc.findFile(ctx, tx, fileID, false)
if err != nil {
return err
}
if found.Public() {
return recordNotFound{}
}
f = found
return nil
})
if !ok {
return
}
ctx := r.Context()
key := attach.BlobKey(f.DiskName)
contentType := f.ContentType
if thumb {
if !slices.Contains(attach.AllowedImageMIMEs, f.ContentType) {
writeNotFound(w, r)
return
}
key, err = f.ThumbKey(ctx, bucket, cf.thumbW, cf.thumbH, cf.thumbMode)
if err != nil {
slog.Default().ErrorContext(ctx, "cabana: protected thumbnail failed", "file_id", f.ID, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
contentType = ""
}
reader, err := bucket.NewReader(ctx, key, nil)
if err != nil {
if gcerrors.Code(err) == gcerrors.NotFound {
writeNotFound(w, r)
return
}
slog.Default().ErrorContext(ctx, "cabana: protected file read failed", "file_id", f.ID, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
defer reader.Close()
if contentType == "" {
contentType = reader.ContentType()
}
contentType = strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Cache-Control", "private, no-store")
h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
if slices.Contains(attach.AllowedImageMIMEs, contentType) {
h.Set("Content-Type", contentType)
} else {
h.Set("Content-Type", "application/octet-stream")
h.Set("Content-Disposition", "attachment; filename*=UTF-8''"+rfc5987(f.FileName))
}
h.Set("Content-Length", strconv.FormatInt(reader.Size(), 10))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, reader)
})
}
// rfc5987 percent-encodes a file name for a filename* parameter: only
// RFC 5987 attr-char bytes stay literal.
func rfc5987(name string) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(name); i++ {
c := name[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9',
strings.IndexByte("!#$&+-.^_`|~", c) >= 0:
b.WriteByte(c)
default:
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&15])
}
}
if b.Len() == 0 {
return "file"
}
return b.String()
}