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 "" }