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
This commit is contained in:
Jakub Zych
2026-10-02 18:11:56 +02:00
parent 044e0450ef
commit e54fd257ee
17 changed files with 2237 additions and 82 deletions

View File

@@ -2,6 +2,7 @@ package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -22,6 +23,7 @@ import (
"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"
)
@@ -734,6 +736,7 @@ func (s *service) writeFileError(w http.ResponseWriter, r *http.Request, cf *com
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})
@@ -768,20 +771,13 @@ func (s *service) writeFileError(w http.ResponseWriter, r *http.Request, cf *com
func logFileFailure(r *http.Request, err error) {
var ve *ValidationError
var missing recordNotFound
var forbidden fileForbidden
if errors.As(err, &ve) || errors.As(err, &missing) || errors.As(err, &forbidden) {
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)
}
// fileForbidden is a file operation the field does not declare (a caption
// without useCaption, a reorder on attachOne): 403.
type fileForbidden struct{}
func (fileForbidden) Error() string { return "cabana: file operation not declared" }
// bodyReader remembers the first read error of the request body other than
// EOF, so a failed upload tells a malformed body from a storage failure.
type bodyReader struct {
@@ -860,3 +856,419 @@ func lockFile(ctx context.Context, tx *gorm.DB, id uint) (*attach.File, error) {
}
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()
}