- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
333 lines
12 KiB
Go
333 lines
12 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"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
|
|
// random key the SPA generates when a form opens and sends with every file
|
|
// upload, file removal and the final save. Work bound to the key is applied
|
|
// by the record's next create or update save, inside its transaction.
|
|
const SessionKeyHeader = "X-Session-Key"
|
|
|
|
// ChildSessionKeyHeader carries the session key of a relation child form
|
|
// (D-17): the child modal's own form key, sent with the child's file calls
|
|
// and with its create or update, which applies the child's file bindings.
|
|
// A child modal on a record that is not saved yet carries both headers.
|
|
const ChildSessionKeyHeader = "X-Child-Session-Key"
|
|
|
|
// sessionKeyPattern is the accepted key shape: 32 to 128 URL-safe
|
|
// characters, at least 128 bits for a base64url key.
|
|
var sessionKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{32,128}$`)
|
|
|
|
// sessionKeyFrom reads the X-Session-Key header. An absent or empty header
|
|
// is ("", false, nil); a malformed key is a validation error on session_key.
|
|
func sessionKeyFrom(r *http.Request) (string, bool, error) {
|
|
raw := strings.TrimSpace(r.Header.Get(SessionKeyHeader))
|
|
if raw == "" {
|
|
return "", false, nil
|
|
}
|
|
if !sessionKeyPattern.MatchString(raw) {
|
|
return "", false, &ValidationError{Details: map[string]any{"session_key": []string{"The session key is invalid."}}}
|
|
}
|
|
return raw, true, nil
|
|
}
|
|
|
|
// childSessionKeyFrom reads the X-Child-Session-Key header like
|
|
// sessionKeyFrom; a malformed key is a validation error on
|
|
// child_session_key.
|
|
func childSessionKeyFrom(r *http.Request) (string, bool, error) {
|
|
raw := strings.TrimSpace(r.Header.Get(ChildSessionKeyHeader))
|
|
if raw == "" {
|
|
return "", false, nil
|
|
}
|
|
if !sessionKeyPattern.MatchString(raw) {
|
|
return "", false, &ValidationError{Details: map[string]any{"child_session_key": []string{"The child session key is invalid."}}}
|
|
}
|
|
return raw, true, nil
|
|
}
|
|
|
|
// commitDeferred applies the file and relation bindings of in.SessionKey to
|
|
// the saved 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 or a
|
|
// deferrable relation-manager relation allowed in op, locked FOR UPDATE so
|
|
// two saves with one key serialize, and applies them in id order. A file
|
|
// bind attaches its pending upload (on an attachOne field after deleting
|
|
// the file it replaces), a file unbind deletes the attached file; blobs of
|
|
// deleted files are removed after commit. A relation bind attaches the
|
|
// related record (applyRelationBinding), a relation unbind detaches it. 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. Any failure (a 422 on a file field, or
|
|
// on a relation-manager field for an ineligible link) rolls the transaction
|
|
// back and leaves the bindings in place.
|
|
func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, op string, in RecordInput) error {
|
|
if cc == nil || cc.Form == nil {
|
|
return nil
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
relations := map[string]*CompiledRelation{}
|
|
for name, cr := range cc.Relations {
|
|
if cr != nil && cr.deferrable && contextAllows(cc, cr.fieldName, op) {
|
|
relations[name] = cr
|
|
}
|
|
}
|
|
ownerID := primaryText(target)
|
|
if (len(fields) == 0 && len(relations) == 0) || ownerID == "" {
|
|
return nil
|
|
}
|
|
morph, err := lagoon.MorphType(tx, target)
|
|
if err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
principal, _ := bouncer.User(ctx)
|
|
if in.SessionKey != "" && principal != nil && principal.Backend && principal.ID != 0 {
|
|
names := make([]string, 0, len(fields)+len(relations))
|
|
for _, cf := range fields {
|
|
names = append(names, cf.name)
|
|
}
|
|
for name := range relations {
|
|
names = append(names, name)
|
|
}
|
|
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)
|
|
}
|
|
rel := RelationService{DB: s.DB, bucket: s.bucket, tr: s.tr}
|
|
parent := &relationParent{model: target, id: pkUint(target)}
|
|
applied := make([]uint, 0, len(rows))
|
|
for _, row := range rows {
|
|
if cf := cc.files[row.MasterField]; cf != nil && row.SlaveType == lagoon.DeferredFileType {
|
|
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)
|
|
continue
|
|
}
|
|
cr := relations[row.MasterField]
|
|
if cr == nil {
|
|
continue
|
|
}
|
|
done, err := rel.applyRelationBinding(ctx, tx, cc, cr, parent, row)
|
|
if err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
if done {
|
|
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{fieldMessage(ctx, s.tr, "required", cf.name, nil)}
|
|
case cf.relation.Many && cf.maxFiles > 0 && n > int64(cf.maxFiles):
|
|
details[cf.name] = []string{fieldMessage(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
|
|
}
|
|
|
|
// applyRelationBinding applies one relation binding of a form session to
|
|
// the saved parent (D-04) and reports whether the row was consumed. A
|
|
// binding of another slave type is left alone. A bind of a child the
|
|
// session created attaches it directly: a hasMany child whose ForeignKey is
|
|
// still NULL gets the parent's key through its model (a child gone or owned
|
|
// elsewhere is skipped); a belongsToMany record gets its pivot row with the
|
|
// envelope's pivot values and RelationBeforeLink stamps. A bind of an
|
|
// existing record runs the shared link path with the saved parent, so the
|
|
// eligibility checks (RelationExtendManageQuery, ExcludedRelatedIDs, not
|
|
// linked yet) run again; an ineligible record fails the save with a 422 on
|
|
// the relation-manager field. An unbind runs the shared unlink path.
|
|
func (s RelationService) applyRelationBinding(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, row lagoon.DeferredBinding) (bool, error) {
|
|
morph, err := lagoon.MorphType(tx, cr.Contract.NewRelated())
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if row.SlaveType != morph {
|
|
return false, nil
|
|
}
|
|
n, err := strconv.ParseUint(row.SlaveID, 10, 64)
|
|
if err != nil || n == 0 {
|
|
return true, nil
|
|
}
|
|
id := uint(n)
|
|
if !row.IsBind {
|
|
_, err := s.unlinkRelated(ctx, tx, cc, cr, parent.model, []uint{id})
|
|
return true, err
|
|
}
|
|
env, err := row.Envelope()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
var pivot map[string]any
|
|
if len(env.Pivot) > 0 && cr.pivot != nil {
|
|
pivot = env.Pivot
|
|
}
|
|
if !env.Created {
|
|
_, err := s.linkRelated(ctx, tx, cc, cr, parent.model, []uint{id}, pivot, relationInvalid(cr.fieldName, "contains an ineligible record"))
|
|
return true, err
|
|
}
|
|
child := cr.Contract.NewRelated()
|
|
err = tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where(clause.Eq{Column: clause.Column{Name: primaryColumn(child)}, Value: castPK(child, id)}).Take(child).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return true, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if cr.hasMany() {
|
|
if fk, ok := structFieldValue(child, cr.Contract.ForeignKey); !ok || !fk.IsNil() {
|
|
return true, nil
|
|
}
|
|
if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil {
|
|
return false, err
|
|
}
|
|
return true, tx.WithContext(ctx).Save(child).Error
|
|
}
|
|
linked, err := pendingRelationIDs(tx, cr, parent.id, []uint{id})
|
|
if err != nil || len(linked) == 0 {
|
|
return true, err
|
|
}
|
|
var pivotRow any
|
|
if pivot != nil {
|
|
pivotRow = cr.Contract.NewPivot()
|
|
if err := s.fillPivot(ctx, tx, cr, pivotRow, pivot); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
return true, insertPivot(ctx, tx, cc, cr, parent.model, child, pivotRow)
|
|
}
|
|
|
|
// structFieldValue is the value of a pointer field stored in column, false
|
|
// when the column is not a pointer field.
|
|
func structFieldValue(model any, column string) (reflect.Value, bool) {
|
|
v := reflect.ValueOf(model)
|
|
for v.Kind() == reflect.Pointer {
|
|
if v.IsNil() {
|
|
return reflect.Value{}, false
|
|
}
|
|
v = v.Elem()
|
|
}
|
|
f := fieldByColumn(v, column)
|
|
if !f.IsValid() || f.Kind() != reflect.Pointer {
|
|
return reflect.Value{}, false
|
|
}
|
|
return f, true
|
|
}
|
|
|
|
// applyFileBind attaches a pending upload to the owner. A row that is gone
|
|
// 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 {
|
|
return nil
|
|
}
|
|
f, err := lockFile(ctx, tx, uint(id))
|
|
if err != nil || f == nil || f.AttachmentID != "" || f.AttachmentType != "" {
|
|
return err
|
|
}
|
|
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 {
|
|
if n := pkUint(model); n > 0 {
|
|
return uitoa(n)
|
|
}
|
|
return ""
|
|
}
|