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
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
package attach
|
||||
|
||||
import "time"
|
||||
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
|
||||
@@ -46,3 +56,86 @@ func All() []any {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user