package cabana import ( "context" "net/http" "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" ) // 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" // 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 } // 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. 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 == "" { return nil } principal, _ := bouncer.User(ctx) if principal == nil || !principal.Backend || principal.ID == 0 { return nil } fields := make([]string, 0, len(cc.files)) for _, field := range cc.Form.Fields { if cf := cc.files[field.Name]; cf != nil && contextAllows(cc, cf.name, op) { fields = append(fields, cf.name) } } if len(fields) == 0 { 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 } if err := s.applyFileBind(ctx, tx, cf, morph, ownerID, row); err != nil { return lifecycleFailure(cc, err) } applied = append(applied, row.ID) } if err := lagoon.DeferredForget(ctx, tx, applied); err != nil { return lifecycleFailure(cc, err) } return nil } // applyFileBind attaches a pending upload to the owner. A row that is gone // or already attached somewhere is ignored. 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 } return tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(&attach.File{}). Where("id = ?", f.ID). Updates(map[string]any{"attachment_type": morph, "attachment_id": ownerID, "field": cf.name}).Error } // 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 "" }