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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user