- 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
195 lines
5.6 KiB
Go
195 lines
5.6 KiB
Go
package attach
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"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
|
|
}
|
|
|
|
func fileIsPublic(ctx context.Context, db *gorm.DB, filename string) (bool, error) {
|
|
if db == nil || filename == "" {
|
|
return false, nil
|
|
}
|
|
var f File
|
|
q := db.WithContext(ctx).Select("is_public")
|
|
var err error
|
|
if id, ok := thumbFileID(filename); ok {
|
|
err = q.First(&f, id).Error
|
|
} else {
|
|
err = q.Where("disk_name = ?", filename).First(&f).Error
|
|
}
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return f.Public(), nil
|
|
}
|
|
|
|
func thumbFileID(name string) (uint, bool) {
|
|
if !strings.HasPrefix(name, "thumb_") {
|
|
return 0, false
|
|
}
|
|
idStr, _, ok := strings.Cut(strings.TrimPrefix(name, "thumb_"), "_")
|
|
if !ok || idStr == "" {
|
|
return 0, false
|
|
}
|
|
n, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil || n == 0 {
|
|
return 0, false
|
|
}
|
|
return uint(n), true
|
|
}
|
|
|
|
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{})
|
|
}
|
|
|
|
// BlobKeys returns the blob keys of f: the original's partitioned key and
|
|
// the thumb_<id>_ prefix of its thumbnails. DeleteKeys treats the second as
|
|
// a prefix, so passing both removes the original and every thumbnail.
|
|
func BlobKeys(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, BlobKeys(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)
|
|
}
|