feat(12.2-01): add deferred bindings, guarded upload store and purge

- deferred_bindings migration set under summercms.deferred with backend_user_id
- lagoon.DeferredBind/Unbind/Bindings/Forget/Slaves scoped by DeferredKey
- lagoon.PurgeDeferred with SKIP LOCKED batches and after-commit blob deletes
- attach.Store with the ported image guard, extension and MIME limits
- attach.Relation, attach.HasRelations, attach.BlobKeys, File.ThumbKey
- lagoon README and attachments docs
This commit is contained in:
Jakub Zych
2026-10-02 17:36:43 +02:00
parent 79e2a43095
commit 19f4cf8232
14 changed files with 1280 additions and 15 deletions

294
modules/lagoon/deferred.go Normal file
View File

@@ -0,0 +1,294 @@
package lagoon
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// DeferredFileType is the slave_type of a deferred binding that points at a
// system_files row: the table name, never a PHP class string.
const DeferredFileType = "system_files"
// DeferredBinding is one row of WinterCMS's deferred_bindings table: a
// pending bind (IsBind true) or unbind of the slave SlaveType/SlaveID to the
// MasterField relation of a MasterType record that one admin's form session
// (SessionKey, BackendUserID) has not saved yet. PivotData holds the
// DeferredEnvelope JSON or is nil.
type DeferredBinding struct {
ID uint `gorm:"column:id;primaryKey"`
MasterType string `gorm:"column:master_type"`
MasterField string `gorm:"column:master_field"`
SlaveType string `gorm:"column:slave_type"`
SlaveID string `gorm:"column:slave_id"`
PivotData *string `gorm:"column:pivot_data"`
SessionKey string `gorm:"column:session_key"`
IsBind bool `gorm:"column:is_bind"`
BackendUserID uint `gorm:"column:backend_user_id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
// TableName is deferred_bindings.
func (DeferredBinding) TableName() string { return "deferred_bindings" }
// DeferredKey identifies one admin's pending work on one master type: the
// form's session key, the backend admin who owns it and the master record's
// morph type (MorphType). Every deferred-binding operation is scoped by all
// three, so a session key used by another admin, or against another model,
// matches nothing.
type DeferredKey struct {
SessionKey string
AdminID uint
MasterType string
}
func (k DeferredKey) validate() error {
if strings.TrimSpace(k.SessionKey) == "" {
return fmt.Errorf("lagoon: deferred binding session key is empty")
}
if k.AdminID == 0 {
return fmt.Errorf("lagoon: deferred binding admin id is zero")
}
if strings.TrimSpace(k.MasterType) == "" {
return fmt.Errorf("lagoon: deferred binding master type is empty")
}
return nil
}
// DeferredEnvelope is the framework's pivot_data shape,
// {"created":true,"pivot":{...}}. Created marks a slave row that was created
// under deferral, which PurgeDeferred deletes with an expired binding; Pivot
// holds the pivot values of a deferred belongsToMany link. A binding without
// the envelope (nil pivot_data, or JSON without these keys) is a plain link.
type DeferredEnvelope struct {
Created bool `json:"created,omitempty"`
Pivot map[string]any `json:"pivot,omitempty"`
}
// Envelope decodes the binding's pivot_data. Nil or empty pivot_data is the
// zero envelope (a plain link); invalid JSON is an error.
func (b DeferredBinding) Envelope() (DeferredEnvelope, error) {
var env DeferredEnvelope
if b.PivotData == nil || strings.TrimSpace(*b.PivotData) == "" {
return env, nil
}
if err := json.Unmarshal([]byte(*b.PivotData), &env); err != nil {
return DeferredEnvelope{}, fmt.Errorf("lagoon: deferred binding %d pivot_data: %w", b.ID, err)
}
return env, nil
}
// MorphType is the master_type or slave_type string of model: its
// attach.Owner MorphName when it implements attach.Owner, else its GORM table
// name under db's naming strategy. An empty result is an error.
func MorphType(db *gorm.DB, model any) (string, error) {
if model == nil {
return "", fmt.Errorf("lagoon: morph type of a nil model")
}
if owner, ok := model.(attach.Owner); ok {
if name := strings.TrimSpace(owner.MorphName()); name != "" {
return name, nil
}
return "", fmt.Errorf("lagoon: morph type of %T is empty", model)
}
if db == nil {
return "", fmt.Errorf("lagoon: gorm db is nil")
}
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(model); err != nil {
return "", fmt.Errorf("lagoon: morph type of %T: %w", model, err)
}
if stmt.Schema == nil || strings.TrimSpace(stmt.Schema.Table) == "" {
return "", fmt.Errorf("lagoon: morph type of %T is empty", model)
}
return stmt.Schema.Table, nil
}
func deferredSession(ctx context.Context, tx *gorm.DB) *gorm.DB {
if ctx == nil {
ctx = context.Background()
}
return tx.Session(&gorm.Session{NewDB: true, Context: ctx})
}
func checkDeferredArgs(tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string) error {
if tx == nil {
return fmt.Errorf("lagoon: gorm db is nil")
}
if err := key.validate(); err != nil {
return err
}
if strings.TrimSpace(field) == "" {
return fmt.Errorf("lagoon: deferred binding field is empty")
}
if strings.TrimSpace(slaveType) == "" {
return fmt.Errorf("lagoon: deferred binding slave type is empty")
}
if strings.TrimSpace(slaveID) == "" {
return fmt.Errorf("lagoon: deferred binding slave id is empty")
}
return nil
}
// findBinding is WinterCMS's DeferredBinding::findBindingRecord, scoped to
// the owning admin and locked for the rest of the transaction.
func findBinding(ctx context.Context, tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string) (*DeferredBinding, error) {
var row DeferredBinding
err := deferredSession(ctx, tx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("master_type = ? AND master_field = ? AND slave_type = ? AND slave_id = ? AND session_key = ? AND backend_user_id = ?",
key.MasterType, field, slaveType, slaveID, key.SessionKey, key.AdminID).
Order("id").
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("lagoon: find deferred binding: %w", err)
}
return &row, nil
}
func insertBinding(ctx context.Context, tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string, bind bool, env *DeferredEnvelope) error {
row := DeferredBinding{
MasterType: key.MasterType,
MasterField: field,
SlaveType: slaveType,
SlaveID: slaveID,
SessionKey: key.SessionKey,
IsBind: bind,
BackendUserID: key.AdminID,
}
if env != nil && (env.Created || len(env.Pivot) > 0) {
raw, err := json.Marshal(env)
if err != nil {
return fmt.Errorf("lagoon: deferred binding envelope: %w", err)
}
s := string(raw)
row.PivotData = &s
}
if err := deferredSession(ctx, tx).Create(&row).Error; err != nil {
return fmt.Errorf("lagoon: insert deferred binding: %w", err)
}
return nil
}
func deleteBinding(ctx context.Context, tx *gorm.DB, id uint) error {
if err := deferredSession(ctx, tx).Where("id = ?", id).Delete(&DeferredBinding{}).Error; err != nil {
return fmt.Errorf("lagoon: delete deferred binding: %w", err)
}
return nil
}
// DeferredBind records that slaveType/slaveID is to be bound to the field
// relation of key's unsaved master, with env (nil for a plain link) as its
// pivot_data. It ports WinterCMS's DeferredBinding::beforeCreate: a second
// bind of the same slave in the same session writes nothing, and a bind
// that meets a pending unbind of the same slave cancels it (the unbind row
// is deleted and no bind row is written). An empty session key, a zero
// AdminID or an empty MasterType is an error.
func DeferredBind(ctx context.Context, tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string, env *DeferredEnvelope) error {
if err := checkDeferredArgs(tx, key, field, slaveType, slaveID); err != nil {
return err
}
existing, err := findBinding(ctx, tx, key, field, slaveType, slaveID)
if err != nil {
return err
}
if existing != nil {
if existing.IsBind {
return nil
}
return deleteBinding(ctx, tx, existing.ID)
}
return insertBinding(ctx, tx, key, field, slaveType, slaveID, true, env)
}
// DeferredUnbind records that slaveType/slaveID is to be unbound from the
// field relation of key's master. A second unbind writes nothing. An unbind
// that meets a pending bind of the same slave cancels the pair: the bind
// row is deleted, no unbind row is written, and the cancelled bind is
// returned so the caller can remove the slave it created (a pending upload
// or a child created under deferral). Otherwise it returns nil.
func DeferredUnbind(ctx context.Context, tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string) (*DeferredBinding, error) {
if err := checkDeferredArgs(tx, key, field, slaveType, slaveID); err != nil {
return nil, err
}
existing, err := findBinding(ctx, tx, key, field, slaveType, slaveID)
if err != nil {
return nil, err
}
if existing != nil {
if !existing.IsBind {
return nil, nil
}
if err := deleteBinding(ctx, tx, existing.ID); err != nil {
return nil, err
}
return existing, nil
}
return nil, insertBinding(ctx, tx, key, field, slaveType, slaveID, false, nil)
}
// DeferredBindings returns key's bindings whose master_field is one of
// fields, in id order, locked FOR UPDATE so two saves with the same session
// key are serialized. No fields means no bindings.
func DeferredBindings(ctx context.Context, tx *gorm.DB, key DeferredKey, fields []string) ([]DeferredBinding, error) {
if tx == nil {
return nil, fmt.Errorf("lagoon: gorm db is nil")
}
if err := key.validate(); err != nil {
return nil, err
}
if len(fields) == 0 {
return nil, nil
}
var rows []DeferredBinding
err := deferredSession(ctx, tx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("session_key = ? AND backend_user_id = ? AND master_type = ? AND master_field IN ?",
key.SessionKey, key.AdminID, key.MasterType, fields).
Order("id").
Find(&rows).Error
if err != nil {
return nil, fmt.Errorf("lagoon: deferred bindings: %w", err)
}
return rows, nil
}
// DeferredForget deletes exactly the bindings with the given ids, after
// their work has been applied. The caller passes only ids it read for its
// own key through DeferredBindings.
func DeferredForget(ctx context.Context, tx *gorm.DB, ids []uint) error {
if tx == nil {
return fmt.Errorf("lagoon: gorm db is nil")
}
if len(ids) == 0 {
return nil
}
if err := deferredSession(ctx, tx).Where("id IN ?", ids).Delete(&DeferredBinding{}).Error; err != nil {
return fmt.Errorf("lagoon: forget deferred bindings: %w", err)
}
return nil
}
// DeferredSlaves returns a subquery selecting the slave_id values of key's
// bindings for field and slaveType in one direction (bind true for pending
// binds, false for pending unbinds), for use as CAST(pk AS TEXT) IN (?) in
// a list query (WinterCMS's withDeferred). slave_id is a text column, so the
// primary key must be cast to text for the comparison.
func DeferredSlaves(tx *gorm.DB, key DeferredKey, field, slaveType string, bind bool) *gorm.DB {
return tx.Session(&gorm.Session{NewDB: true}).
Model(&DeferredBinding{}).
Select("slave_id").
Where("session_key = ? AND backend_user_id = ? AND master_type = ? AND master_field = ? AND slave_type = ? AND is_bind = ?",
key.SessionKey, key.AdminID, key.MasterType, field, slaveType, bind)
}