- docs/backend: admin controllers, forms, lists and filters, relation manager, users and permissions, settings, partials and widgets, admin SPA - docs/services: storage, outbound HTTP, realtime, Web Push, search, parity testing and the Frontend and AJAX (not provided) page - Examples for cabana (with testdata/docs YAML), fetchguard, lighthouse and its centrifugo driver, flare, beachcomber and typesense, tide; lighthouse and beachcomber TestDocs* regions run on their Postgres harnesses - concept map rows link their guide pages and the not-provided rows the Frontend and AJAX page; index lists Backend, Database and Services - TestDocsRequiredPages asserts the D-08 section order
98 lines
4.5 KiB
Markdown
98 lines
4.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.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](transactions.md).
|