Database
Attachments
Attach files to models through WinterCMS-compatible system_files rows, serve originals and thumbnails, and delete blobs only after the transaction commits.
WinterCMS attaches files to models with $attachOne and $attachMany, storing a row per file in system_files and the bytes on a disk. The attach package of lagoon ports the same table and storage layout, so files uploaded to a WinterCMS site keep working after a data copy. The bytes live in a gocloud.dev bucket; see Storage for configuring it.
The system_files row#
attach.File is the system_files row. It links a file to its owner with the WinterCMS polymorphic columns: AttachmentType holds the owner's morph name and AttachmentID its ID, as a string, and Field names the relation, such as cover or gallery. The system_files table is created by the framework migrations, so a plugin does not migrate it.
An owner model implements attach.Owner. Its MorphName returns the PHP class name, so the attachment_type values copied from WinterCMS still match:
func (Post) MorphName() string { return `Acme\Blog\Models\Post` }
Storing and serving files#
The original is stored under WinterCMS's partitioned key: attach.PartitionDirectory splits the first nine characters of the random disk_name into three directories, and attach.BlobKey appends the name. attach.File.Thumb returns the public URL of a thumbnail, generating it in the same partition on first use and reusing it afterwards:
ctx := context.Background()
dir, err := os.MkdirTemp("", "acme-config")
if err != nil {
fmt.Println(err)
return
}
defer os.RemoveAll(dir)
// storage.uploads.bucket_url is a file:// URL in production; mem:// keeps
// the example in memory.
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
_ = cfg.Set("storage.uploads.bucket_url", "mem://")
bucket, err := attach.OpenBucket(ctx, cfg)
if err != nil {
fmt.Println(err)
return
}
defer bucket.Close()
// The system_files row of a post's cover image.
var owner attach.Owner = Post{ID: 1}
f := attach.File{
ID: 7,
DiskName: "5f1d0c2e9a7b4c3d8e6f.jpg",
FileName: "cover.jpg",
ContentType: "image/jpeg",
Field: "cover",
AttachmentType: owner.MorphName(),
AttachmentID: "1",
}
// The original is stored under its partitioned WinterCMS key.
var img bytes.Buffer
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 640, 480)), nil); err != nil {
fmt.Println(err)
return
}
if err := bucket.WriteAll(ctx, attach.BlobKey(f.DiskName), img.Bytes(), nil); err != nil {
fmt.Println(err)
return
}
fmt.Println(attach.BlobKey(f.DiskName))
// A thumbnail is generated on first use and reused afterwards.
url, err := f.Thumb(ctx, bucket, 200, 200, "crop")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(url)
// Output:
// 5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg
// /storage/uploads/5f1/d0c/2e9/thumb_7_200_200_0_0_crop.jpg
URLs start with storage.uploads.public_path_prefix (/storage/uploads by default). attach.StaticHandler serves originals and thumbnails under that prefix; attach.StaticHandlerPublic does the same and answers 404 for a row whose is_public flag is false. Mount the gated handler when a bucket holds any private file.
Deleting files after commit#
A rolled-back transaction can restore a row but not the bytes of a deleted blob. Deleting an owner's files is therefore split in two:
- Inside the transaction that deletes the owner,
attach.DeleteForOwnerdeletes the owner'ssystem_filesrows and passes their blob keys to a callback. The callback only records the keys; it must not delete anything. - After the transaction commits,
attach.DeleteKeysdeletes the originals and their thumbnails from the bucket.
A soft-deleted owner keeps its rows and files, so only a force delete (Unscoped().Delete) runs this. lagoon.AfterCommit is a natural place for the second step; see Transactions.