- type: datepicker compiles the D-20 keys; format maps to displayFormat with WinterCMS's momentFormat table - boot fails when the mode does not match the column's Go type (time.Time, lagoon.Date, lagoon.TimeOfDay) - datepicker is a writable scalar field; minDate and maxDate are rechecked on save - columns.yaml accepts type: date and type: time; Scanner/Valuer structs are columns, not relations - conformance fixture carries date and datetime fields; README, forms and lists docs
1279 lines
39 KiB
Go
1279 lines
39 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 && typ != "datepicker" {
|
|
return fmt.Errorf("mode is only valid on type: fileupload or datepicker")
|
|
}
|
|
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, fieldMessage(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 = fieldMessage(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 = fieldMessage(ctx, tr, "mimes", cf.name, map[string]string{"values": strings.Join(types, ", ")})
|
|
case errors.Is(err, attach.ErrMIMEType):
|
|
detail = fieldMessage(ctx, tr, "mimetypes", cf.name, map[string]string{"values": strings.Join(cf.limits.MIMETypes, ", ")})
|
|
case errors.Is(err, attach.ErrNotImage):
|
|
detail = fieldMessage(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}}
|
|
}
|
|
|
|
// fieldMessage is a lagoon::validation line for a file or date field, with
|
|
// Laravel's English text when no translator has the key.
|
|
func fieldMessage(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."
|
|
case "after_or_equal":
|
|
return "The " + attr + " must be a date after or equal to " + params["date"] + "."
|
|
case "before_or_equal":
|
|
return "The " + attr + " must be a date before or equal to " + params["date"] + "."
|
|
}
|
|
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()
|
|
}
|