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"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SessionKeyHeader carries the admin SPA's form session key (D-02): a
|
||||
@@ -37,60 +39,95 @@ func sessionKeyFrom(r *http.Request) (string, bool, error) {
|
||||
}
|
||||
|
||||
// commitDeferred applies the file bindings of in.SessionKey to the saved
|
||||
// target inside the save transaction (D-04). It reads every binding of the
|
||||
// key, the authenticated admin and the controller's morph type whose
|
||||
// master_field is a fileupload field allowed in op, locked FOR UPDATE so two
|
||||
// saves with one key serialize; binds attach their pending file, and the
|
||||
// applied rows are deleted. Bindings of other fields stay for the purge.
|
||||
// target inside the save transaction (D-04), then rechecks the file limits.
|
||||
//
|
||||
// It reads every binding of the key, the authenticated admin and the
|
||||
// controller's morph type whose master_field is a fileupload field allowed
|
||||
// in op, locked FOR UPDATE so two saves with one key serialize, and applies
|
||||
// them in id order: a bind attaches its pending upload (on an attachOne
|
||||
// field after deleting the file it replaces), an unbind deletes the attached
|
||||
// file. Blobs of deleted files are removed after commit, and the applied
|
||||
// rows are deleted. Bindings of other fields stay for the purge. Then every
|
||||
// fileupload field allowed in op must hold at most maxFiles files and, when
|
||||
// required, at least one; otherwise the save fails with a 422 on the field,
|
||||
// the transaction rolls back and the bindings stay in place.
|
||||
func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, op string, in RecordInput) error {
|
||||
if cc == nil || len(cc.files) == 0 || in.SessionKey == "" {
|
||||
if cc == nil || cc.Form == nil || len(cc.files) == 0 {
|
||||
return nil
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if principal == nil || !principal.Backend || principal.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
fields := make([]string, 0, len(cc.files))
|
||||
var fields []*compiledFile
|
||||
for _, field := range cc.Form.Fields {
|
||||
if cf := cc.files[field.Name]; cf != nil && contextAllows(cc, cf.name, op) {
|
||||
fields = append(fields, cf.name)
|
||||
fields = append(fields, cf)
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
ownerID := primaryText(target)
|
||||
if len(fields) == 0 || ownerID == "" {
|
||||
return nil
|
||||
}
|
||||
morph, err := lagoon.MorphType(tx, target)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
key := lagoon.DeferredKey{SessionKey: in.SessionKey, AdminID: principal.ID, MasterType: morph}
|
||||
rows, err := lagoon.DeferredBindings(ctx, tx, key, fields)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
ownerID := primaryText(target)
|
||||
if ownerID == "" {
|
||||
return nil
|
||||
}
|
||||
applied := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
cf := cc.files[row.MasterField]
|
||||
if cf == nil || row.SlaveType != lagoon.DeferredFileType || !row.IsBind {
|
||||
continue
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if in.SessionKey != "" && principal != nil && principal.Backend && principal.ID != 0 {
|
||||
names := make([]string, len(fields))
|
||||
for i, cf := range fields {
|
||||
names[i] = cf.name
|
||||
}
|
||||
if err := s.applyFileBind(ctx, tx, cf, morph, ownerID, row); err != nil {
|
||||
key := lagoon.DeferredKey{SessionKey: in.SessionKey, AdminID: principal.ID, MasterType: morph}
|
||||
rows, err := lagoon.DeferredBindings(ctx, tx, key, names)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
applied := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
cf := cc.files[row.MasterField]
|
||||
if cf == nil || row.SlaveType != lagoon.DeferredFileType {
|
||||
continue
|
||||
}
|
||||
if row.IsBind {
|
||||
err = s.applyFileBind(ctx, tx, cf, morph, ownerID, row)
|
||||
} else {
|
||||
err = s.applyFileUnbind(ctx, tx, cf, morph, ownerID, row)
|
||||
}
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
applied = append(applied, row.ID)
|
||||
}
|
||||
if err := lagoon.DeferredForget(ctx, tx, applied); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
applied = append(applied, row.ID)
|
||||
}
|
||||
if err := lagoon.DeferredForget(ctx, tx, applied); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
details := map[string]any{}
|
||||
for _, cf := range fields {
|
||||
if !cf.required && (!cf.relation.Many || cf.maxFiles == 0) {
|
||||
continue
|
||||
}
|
||||
var n int64
|
||||
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).
|
||||
Where("attachment_type = ? AND attachment_id = ? AND field = ?", morph, ownerID, cf.name).
|
||||
Count(&n).Error
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
switch {
|
||||
case cf.required && n == 0:
|
||||
details[cf.name] = []string{fileMessage(ctx, s.tr, "required", cf.name, nil)}
|
||||
case cf.relation.Many && cf.maxFiles > 0 && n > int64(cf.maxFiles):
|
||||
details[cf.name] = []string{fileMessage(ctx, s.tr, "max.array", cf.name, map[string]string{"max": strconv.Itoa(cf.maxFiles)})}
|
||||
}
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return &ValidationError{Details: details}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyFileBind attaches a pending upload to the owner. A row that is gone
|
||||
// or already attached somewhere is ignored.
|
||||
// or already attached somewhere is ignored. On an attachOne field the file
|
||||
// it replaces is deleted first (WinterCMS's AttachOne::add).
|
||||
func (s CRUDService) applyFileBind(ctx context.Context, tx *gorm.DB, cf *compiledFile, morph, ownerID string, row lagoon.DeferredBinding) error {
|
||||
id, err := strconv.ParseUint(row.SlaveID, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
@@ -100,11 +137,56 @@ func (s CRUDService) applyFileBind(ctx context.Context, tx *gorm.DB, cf *compile
|
||||
if err != nil || f == nil || f.AttachmentID != "" || f.AttachmentType != "" {
|
||||
return err
|
||||
}
|
||||
return tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}).
|
||||
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
|
||||
if !cf.relation.Many {
|
||||
var previous []attach.File
|
||||
err := q.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("attachment_type = ? AND attachment_id = ? AND field = ? AND id <> ?", morph, ownerID, cf.name, f.ID).
|
||||
Find(&previous).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, old := range previous {
|
||||
if err := s.deleteFile(ctx, tx, old); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return q.Model(&attach.File{}).
|
||||
Where("id = ?", f.ID).
|
||||
Updates(map[string]any{"attachment_type": morph, "attachment_id": ownerID, "field": cf.name}).Error
|
||||
}
|
||||
|
||||
// applyFileUnbind deletes a file attached to this owner and field; a file
|
||||
// that is not attached there is ignored.
|
||||
func (s CRUDService) applyFileUnbind(ctx context.Context, tx *gorm.DB, cf *compiledFile, morph, ownerID string, row lagoon.DeferredBinding) error {
|
||||
id, err := strconv.ParseUint(row.SlaveID, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil
|
||||
}
|
||||
var f attach.File
|
||||
err = tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND attachment_type = ? AND attachment_id = ? AND field = ?", id, morph, ownerID, cf.name).
|
||||
Take(&f).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deleteFile(ctx, tx, f)
|
||||
}
|
||||
|
||||
// deleteFile deletes a file row now and its blobs after commit.
|
||||
func (s CRUDService) deleteFile(ctx context.Context, tx *gorm.DB, f attach.File) error {
|
||||
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, s.bucket, f)
|
||||
return nil
|
||||
}
|
||||
|
||||
// primaryText is the saved record's primary key as system_files stores it
|
||||
// in attachment_id (Winter keeps the morph key as a string).
|
||||
func primaryText(model any) string {
|
||||
|
||||
Reference in New Issue
Block a user