Files
summercms/docs/database/attachments.md
Jakub Zych e06e0cc8bf feat(12-01): record multipart uploads and match Winter upload URLs
- attach.PublicURL and (*File).URL build Winter File::getPath() URLs; the
  thumbnailer decodes webp via golang.org/x/image v0.46.0 and checks the
  image size from the header before decoding
- tide requests carry multipart parts (files beside the fixture pinned by
  sha256) encoded with the fixed MultipartBoundary, so PHP and Go receive
  byte-identical bodies
- tide masks the random partition, disk name and file id of url/thumb_url
  upload URLs while still diffing prefix, size, mode and extension, and
  NormalizePublications masks Carbon dates in the published album
2026-10-02 11:33:42 +02:00

5.1 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 and refuses an image larger than 4096 by 4096 pixels. 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.

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.