Files
summercms/docs/database/attachments.md
Jakub Zych e54fd257ee feat(12.2-02): add file removal, caption, reorder and protected downloads
- DELETE, PUT and POST reorder under .../{id}/files/{field}, each scoped by one parent query (404 for a foreign file)
- protected download and thumb routes: is_public=false only, nosniff, private no-store, sandbox CSP, inline only for jpeg/png/gif/webp
- the save applies deferred removals, replaces attachOne files and rechecks maxFiles and required
- blobs of deleted files are removed after commit
- swagger2openapi emits binary content for file responses
- admin OpenAPI, TS types, conformance, README and attachments docs
2026-10-02 18:11:56 +02:00

125 lines
9.5 KiB
Markdown

---
title: Attachments
description: Attach files to models through WinterCMS-compatible system_files rows, serve originals and thumbnails, and delete blobs only after the transaction commits.
section: database
order: 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](../../modules/lagoon/README.md) 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](https://gocloud.dev/howto/blob/) bucket; see [Storage](../services/storage.md) 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:
```go src=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:
```go src=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.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](../services/storage.md#the-wintercms-layout) 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.
### Protected files in the admin
A `type: fileupload` field on a protected relation never shows a public URL in the admin; see [Forms](../backend/forms.md#file-uploads). The admin SPA reads such a file through two authenticated admin API routes under the `backend` guard:
- GET `{prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download` streams the original.
- GET `{prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb` streams the preview thumbnail, built with `attach.File.ThumbKey`; a file that is not a JPEG, PNG, GIF or WebP image has none.
Both routes look the file up with one query scoped to its record, which the controller's `pact.FormExtendQuery` must let the administrator load, or to an upload pending in the administrator's own form session (`X-Session-Key`). A file of another record, a public file and a file outside that scope all answer 404. Every response carries `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and `Content-Security-Policy: default-src 'none'; sandbox`. Only JPEG, PNG, GIF and WebP are served inline with their own type; any other file, an SVG included, is sent as an `application/octet-stream` attachment, so a stored file never runs script in the admin's 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. `attach.BlobKeys` lists 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](transactions.md).