package attach import ( "context" "fmt" "io" "strings" "time" "gocloud.dev/blob" "gocloud.dev/gcerrors" "gorm.io/gorm" ) // Owner is implemented by models that own system_files rows. MorphName // must return the PHP class string so cutover-copied attachment_type // values keep matching. type Owner interface { MorphName() string } // File is Winter's system_files row. AttachmentID is a string because // Winter stores the morph FK as a string, never an integer. type File struct { ID uint `gorm:"column:id;primaryKey"` DiskName string `gorm:"column:disk_name"` FileName string `gorm:"column:file_name"` FileSize int64 `gorm:"column:file_size"` ContentType string `gorm:"column:content_type"` Title *string `gorm:"column:title"` Description *string `gorm:"column:description"` Field string `gorm:"column:field"` AttachmentID string `gorm:"column:attachment_id"` AttachmentType string `gorm:"column:attachment_type"` // Pointer so GORM can distinguish unset (nil → DEFAULT TRUE) from // explicit false. A non-pointer bool with default:true cannot persist // false because false is the zero value GORM replaces with the default. IsPublic *bool `gorm:"column:is_public;not null;default:true"` SortOrder int `gorm:"column:sort_order"` Metadata *string `gorm:"column:metadata"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` } func (File) TableName() string { return "system_files" } // Public reports Winter's is_public flag. A nil pointer is treated as true, // matching the SQL DEFAULT TRUE and File::create() behaviour. func (f File) Public() bool { if f.IsPublic == nil { return true } return *f.IsPublic } var all []any // Register appends models so schema tooling can see File without a plugin registry. func Register(models ...any) { all = append(all, models...) } // All returns every model registered in this package. func All() []any { return all } func init() { Register(&File{}) } func blobKeysFor(f File) []string { part := PartitionDirectory(f.DiskName) return []string{ part + f.DiskName, part + fmt.Sprintf("thumb_%d_", f.ID), } } // DeleteForOwner removes system_files rows for owner inside tx. // // Two-phase contract (GORM has no post-commit hook): // 1. Inside tx this function SELECTs disk_name values, DELETEs the rows, // and invokes afterCommit with the blob keys so the caller can record // them. afterCommit must not delete blobs — a rollback cannot restore // bytes. // 2. After the top-level Unscoped().Delete(...) returns without error // (the transaction has committed), the caller passes those keys to // DeleteKeys to remove originals and thumbs from the bucket. // // Soft-deleting an owner must not call this helper: rows and blobs stay. func DeleteForOwner(tx *gorm.DB, owner Owner, ownerID string, afterCommit func(blobKeys []string) error) error { if tx == nil { return fmt.Errorf("attach: delete tx is nil") } if owner == nil { return fmt.Errorf("attach: delete owner is nil") } morph := owner.MorphName() var files []File if err := tx.Where("attachment_type = ? AND attachment_id = ?", morph, ownerID).Find(&files).Error; err != nil { return err } var keys []string for _, f := range files { keys = append(keys, blobKeysFor(f)...) } if err := tx.Where("attachment_type = ? AND attachment_id = ?", morph, ownerID).Delete(&File{}).Error; err != nil { return err } if afterCommit != nil { return afterCommit(keys) } return nil } // DeleteKeys removes blob objects after a committed force-delete. // Keys that end in '_' are treated as List prefixes (thumb__). func DeleteKeys(ctx context.Context, bucket *blob.Bucket, keys []string) error { if bucket == nil { return fmt.Errorf("attach: bucket is nil") } for _, key := range keys { if strings.HasSuffix(key, "_") || strings.HasSuffix(key, "/") { iter := bucket.List(&blob.ListOptions{Prefix: key}) for { obj, err := iter.Next(ctx) if err == io.EOF { break } if err != nil { return fmt.Errorf("attach: list %q: %w", key, err) } if err := deleteKey(ctx, bucket, obj.Key); err != nil { return err } } continue } if err := deleteKey(ctx, bucket, key); err != nil { return err } } return nil } func deleteKey(ctx context.Context, bucket *blob.Bucket, key string) error { err := bucket.Delete(ctx, key) if err == nil || gcerrors.Code(err) == gcerrors.NotFound { return nil } return fmt.Errorf("attach: delete %q: %w", key, err) }