Database

Attachments

Attach files to models through WinterCMS-compatible system_files rows, serve originals and thumbnails, and delete blobs only after the transaction commits.

On this page

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:

modules/lagoon/attach/example_test.go#Post.MorphName
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:

modules/lagoon/attach/example_test.go#ExampleFile_Thumb
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:

  1. Inside the transaction that deletes the owner, attach.DeleteForOwner deletes the owner's system_files rows and passes their blob keys to a callback. The callback only records the keys; it must not delete anything.
  2. After the transaction commits, attach.DeleteKeys deletes 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.