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) { s.fileListOn(w, r, cc, parentFiles(cc)) }) } func (s *service) fileListOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) { 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 := fr.scope(ctx, tx, r) 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) { s.fileUploadOn(w, r, cc, parentFiles(cc)) }) } func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) { cf := fr.field(r) if cf == nil { writeNotFound(w, r) return } if !requireKey(w, r, fr) { 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 := fr.scope(ctx, tx, r) 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, fr fileRoute, 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 := fr.scope(ctx, tx, r) if err != nil { return err } return fn(ctx, tx, sc) }) if err != nil { s.writeFileError(w, r, fr.field(r), nil, err) return false } return true } // requireKey answers 422 when the request has no valid file session key: // X-Session-Key on a record's file routes, X-Child-Session-Key on a // relation child's. func requireKey(w http.ResponseWriter, r *http.Request, fr fileRoute) bool { _, ok, err := fr.keyFrom(r) if err == nil && !ok { err = &ValidationError{Details: map[string]any{fr.keyName: []string{"The " + strings.ReplaceAll(fr.keyName, "_", " ") + " field is required."}}} } if err != nil { writeCRUDError(w, err) return false } return true } // fileRoute is the target of a file route: a record's own fileupload fields // (the record file routes) or the manage form fields of a relation child // (the child file routes, D-17). The handlers are shared; the route decides // which fields exist, which header carries the file session key and how the // owner is scoped. type fileRoute struct { files map[string]*compiledFile keyFrom func(*http.Request) (string, bool, error) keyName string scope func(ctx context.Context, tx *gorm.DB, r *http.Request) (*fileScope, error) } // field is the route's fileupload field, or nil. func (fr fileRoute) field(r *http.Request) *compiledFile { return fr.files[r.PathValue("field")] } // parentFiles is the file route of the record's own fileupload fields. func parentFiles(cc *CompiledController) fileRoute { return fileRoute{ files: cc.files, keyFrom: sessionKeyFrom, keyName: "session_key", scope: func(ctx context.Context, tx *gorm.DB, r *http.Request) (*fileScope, error) { return parentFileScope(ctx, tx, r, cc) }, } } // childFiles is the file route of a relation child form's fileupload // fields (D-17), after the capability check: child 0 (a child not created // yet) needs the create button, else 404 as for a record's id 0; a saved // child needs the update button to write and update or a view form to // read, else 403. An unknown relation or child form is 404. func (s *service) childFiles(w http.ResponseWriter, r *http.Request, cc *CompiledController, write bool) (fileRoute, bool) { cr, err := relationOf(cc, r.PathValue("name")) if err == nil && cr.child == nil { err = recordNotFound{} } if err != nil { writeCRUDError(w, err) return fileRoute{}, false } n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("child")), 10, 64) if err != nil { writeNotFound(w, r) return fileRoute{}, false } childID := uint(n) switch { case childID == 0 && !cr.allows("create"): writeNotFound(w, r) return fileRoute{}, false case childID > 0 && (write && !cr.allows("update") || !write && cr.childForm() == nil): if principal, _ := bouncer.User(r.Context()); principal != nil { s.logAuth(r, "denied", principal.ID) } WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) return fileRoute{}, false } return fileRoute{ files: cr.child.files, keyFrom: childSessionKeyFrom, keyName: "child_session_key", scope: func(ctx context.Context, tx *gorm.DB, r *http.Request) (*fileScope, error) { return childFileScope(ctx, tx, r, cc, cr, childID) }, }, true } // relationChildFileList and the six handlers below serve the file routes // of a relation child form under .../relations/{name}/records/{child}. func (s *service) relationChildFileList(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if fr, ok := s.childFiles(w, r, cc, false); ok { s.fileListOn(w, r, cc, fr) } }) } func (s *service) relationChildFileUpload(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if fr, ok := s.childFiles(w, r, cc, true); ok { s.fileUploadOn(w, r, cc, fr) } }) } func (s *service) relationChildFileUpdate(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if fr, ok := s.childFiles(w, r, cc, true); ok { s.fileUpdateOn(w, r, cc, fr) } }) } func (s *service) relationChildFileRemove(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if fr, ok := s.childFiles(w, r, cc, true); ok { s.fileRemoveOn(w, r, cc, fr) } }) } func (s *service) relationChildFileReorder(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if fr, ok := s.childFiles(w, r, cc, true); ok { s.fileReorderOn(w, r, cc, fr) } }) } func (s *service) relationChildFileDownload(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if fr, ok := s.childFiles(w, r, cc, false); ok { s.serveProtectedFileOn(w, r, cc, fr, false) } }) } func (s *service) relationChildFileThumb(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { if fr, ok := s.childFiles(w, r, cc, false); ok { s.serveProtectedFileOn(w, r, cc, fr, 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) { s.fileRemoveOn(w, r, cc, parentFiles(cc)) }) } func (s *service) fileRemoveOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) { if fr.field(r) == nil { writeNotFound(w, r) return } if !requireKey(w, r, fr) { return } fileID, err := pathFileID(r) if err != nil { writeCRUDError(w, err) return } bucket := s.bucket() ok := s.withFileScope(w, r, cc, fr, 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) { s.fileUpdateOn(w, r, cc, parentFiles(cc)) }) } func (s *service) fileUpdateOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) { cf := fr.field(r) 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, fr, 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) { s.fileReorderOn(w, r, cc, parentFiles(cc)) }) } func (s *service) fileReorderOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute) { cf := fr.field(r) 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, fr, 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) { s.serveProtectedFileOn(w, r, cc, parentFiles(cc), thumb) }) } func (s *service) serveProtectedFileOn(w http.ResponseWriter, r *http.Request, cc *CompiledController, fr fileRoute, thumb bool) { cf := fr.field(r) 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, fr, 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() }