- 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
8.3 KiB
title, description, section, order
| title | description | section | order |
|---|---|---|---|
| Attachments | Attach files to models through WinterCMS-compatible system_files rows, serve originals and thumbnails, and delete blobs only after the transaction commits. | database | 60 |
Attachments
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.File.URL returns the URL of the original, the path WinterCMS's File::getPath() returns, and attach.PublicURL the URL of any blob key; Storage shows the configuration that reproduces WinterCMS's URLs exactly.
Originals in JPEG, PNG, GIF and WebP can be thumbnailed. The thumbnailer cannot write WebP, so the thumbnail of a .webp original holds JPEG bytes under the original's .webp name, and it is stored with the image/jpeg content type. Before decoding, the thumbnailer reads the image size from the file header, so an image larger than 4096 by 4096 pixels is never decoded. As WinterCMS's File::makeThumb does, an original that is missing, does not decode or is too large gets WinterCMS's 200 by 200 broken-image picture (attach.BrokenImagePNG) stored as its thumbnail, and the reason is logged at warn level: one unusable upload never makes the pages that list it fail. 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.
Warning
Serve uploads from a separate origin, or at least never mount the ungated handler on the application's own origin. An uploaded file served with its own content type from the API's origin can run script in that origin.
Storing an upload
attach.Store turns an upload into a system_files row. It takes an attach.Upload (the client's file name, the body and the public flag) and an attach.Limits, writes the bytes under the WinterCMS partition key and inserts an unattached row whose sort_order equals its id, as WinterCMS's Sortable trait does. The caller attaches the row later by setting Field, AttachmentType and AttachmentID, usually when the record's form is saved. When the row insert fails, the blob is deleted again.
The client file name never reaches a blob key. The disk name is 22 random hexadecimal characters plus the lower-cased extension, and the extension must be one to ten letters or digits from the allowed list: Limits.Extensions, or, when that is empty, attach.DefaultImageExtensions in image mode (jpg, jpeg, png, gif and webp) and attach.DefaultFileExtensions otherwise. The file list is WinterCMS's default list without the types that can carry script: svg, js, map, css, less, scss, swf and xml. A refused extension returns attach.ErrFileType.
The content type is sniffed from the first bytes of the body, never taken from the client. Limits.MIMETypes narrows it further: an entry with a slash is a MIME pattern such as image/png or image/*, an entry without one is an extension, and a file that matches no entry returns attach.ErrMIMEType. Limits.MaxBytes is enforced while the body streams into the bucket; a body one byte over the limit returns attach.ErrTooLarge and leaves no blob and no row behind.
With Limits.Image set, the content must pass attach.IsAllowedImage: the bytes must sniff as one of attach.AllowedImageMIMEs (JPEG, PNG, GIF or WebP), the header must decode as that format, and the image may not exceed attach.MaxImagePixels (4096 by 4096), so every accepted image can be thumbnailed. Anything else, an SVG for example, returns attach.ErrNotImage.
A model declares its attachment relations, the Go form of WinterCMS's $attachOne and $attachMany, by implementing attach.HasRelations. Each attach.Relation has a Name (the field column), Many (attachMany rather than attachOne) and Public, which decides the is_public flag of the files stored through it. The model also implements attach.Owner, because the morph name fills attachment_type.
Protected files
Public and protected files live in the same bucket. A protected file (is_public false) is kept private by three rules: its disk name is unguessable, the framework never builds a public URL for it, and the host application mounts attach.StaticHandlerPublic, which answers 404 for it, or serves the bucket with directory listing turned off. attach.File.ThumbKey returns a thumbnail's blob key instead of its URL, so an authenticated route can stream a protected thumbnail itself.
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.attach.BlobKeyslists the keys of one file: its original and the prefix of its thumbnails.
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.