Files
summercms/lagoon/attach/file.go
Jakub Zych d8ecb41981 feat(05-04): add attach delete lifecycle and static file handler
- DeleteForOwner removes system_files rows in-tx; blobs after commit
- Soft-delete of an owner keeps rows and blobs
- StaticHandler serves exact partition+disk_name keys and 404s traversal
2026-09-18 20:00:31 +02:00

142 lines
4.1 KiB
Go

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"`
IsPublic bool `gorm:"column:is_public"`
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" }
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_<id>_).
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)
}