feat(19.3-03): storage.uploads.public_url for direct bucket or CDN file URLs

- OpenBucket validates public_url (absolute http/https, host, no user info, query
  or fragment; the error names the key) and records it next to public_path_prefix
- a set public_url logs one start-up line: the bucket behind it must hold only
  public files, since it bypasses the is_public check
- attach.PublicURLBase; PublicURL, File.URL and thumbnail URLs use the base when
  set and keep the public_path_prefix proxy URLs otherwise
- TestPublicURLSmoke; storage Public URL base section, attachments, configuration
  and lagoon README
This commit is contained in:
Jakub Zych
2026-10-15 18:13:00 +02:00
parent 2e22e9f9a6
commit 2e87efae54
7 changed files with 127 additions and 9 deletions

View File

@@ -82,7 +82,7 @@ fmt.Println(url)
// /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.
URLs start with `storage.uploads.public_path_prefix` (`/storage/uploads` by default), or with `storage.uploads.public_url` when a public bucket or CDN serves the files (see [Public URL base](../services/storage.md#public-url-base)). `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.

View File

@@ -79,6 +79,22 @@ Credentials come from the standard AWS environment: `AWS_ACCESS_KEY_ID` and `AWS
The [Serving files](#serving-files) route reads through the bucket, so it serves an S3 bucket exactly as it serves a directory.
## Public URL base
By default every file and thumbnail URL starts with `public_path_prefix`, a path on the application's own host. When a public bucket or a CDN serves the files directly, set `storage.uploads.public_url` (environment `SUMMER_STORAGE__UPLOADS__PUBLIC_URL`) to its base URL:
```yaml
uploads:
bucket_url: s3://acme-uploads?region=eu-central-1
public_url: https://cdn.example.com/uploads
```
`attach.PublicURL`, `attach.File.URL` and thumbnail URLs then are that base, one slash and the blob key, which keeps the partition layout: `https://cdn.example.com/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg`. `attach.PublicURLBase` returns the base. The value must be an absolute `http` or `https` URL with a host and without user info, query or fragment; anything else stops the start-up with an error naming the key. Thumbnails are written to the bucket when they are first requested through `attach.File.Thumb`, so their URLs work behind the base as well.
The base applies to every URL the framework builds, private files included. The `/storage/uploads` route checks `is_public` before it serves a file; a public bucket or a CDN serves any key it holds. Use `public_url` only for a bucket that holds public files alone, or knowingly rely on the 22 random hexadecimal characters of each disk name. `serve` logs one line at start-up naming the host and this rule.
Leave `public_url` unset to keep the URLs under `public_path_prefix`. With `storage.uploads.serve` on, those URLs go through the [Serving files](#serving-files) route, which works for an S3 bucket too, and private files stay protected.
## Serving files
The framework mounts no file route unless you ask for one. Set `storage.uploads.serve` to `true` (environment `SUMMER_STORAGE__UPLOADS__SERVE=true`, default `false`) and the router registers `GET <public_path_prefix>/{path...}` on `attach.StaticHandlerPublic`:

View File

@@ -58,6 +58,7 @@ These are the keys most applications set. Each module's reference page lists all
| `redis.url` | Redis URL (`redis://`, `rediss://` or `unix://`), required when a Redis cache or rate-limit store is selected. Keep it in `SUMMER_REDIS__URL` when it carries a password. | [cooler](../../modules/cooler/README.md) |
| `http.access_log` | Logs one record per request with method, matched route pattern, status and duration. Defaults to `false`. | [surf](../../modules/surf/README.md) |
| `storage.uploads.bucket_url` | Uploads bucket, `file://`, `mem://` or `s3://` (the last only in a binary built with the `summercms_s3` tag from `build.tags` in `summer.yaml`; credentials from the AWS environment). Required by `serve`. | [lagoon](../../modules/lagoon/README.md) |
| `storage.uploads.public_url` | Absolute `http` or `https` base of a public bucket or CDN; file and thumbnail URLs become that base plus the partition path. Unset keeps `storage.uploads.public_path_prefix`. Only for a bucket holding public files alone. | [lagoon](../../modules/lagoon/README.md) |
| `storage.uploads.serve` | Mounts `attach.StaticHandlerPublic` under `storage.uploads.public_path_prefix` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Defaults to `false`. | [surf](../../modules/surf/README.md) |
| `admin.jwt.secret` | Secret for admin tokens. Required as soon as a plugin registers an admin controller. | [cabana](../../modules/cabana/README.md) |
| `backend.uri` | Path the admin is served under. Defaults to `/backend`. | [cabana](../../modules/cabana/README.md) |

View File

@@ -38,7 +38,7 @@ Use cases:
- Lifecycle and relations: hook interfaces matching GORM's native method names (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) plus `lagoon.HasBeforeValidate`; `lagoon.WithSoftDeleteCascade` runs a cascade inside the parent delete; `lagoon.RegisterJoinTable` wires pivot models with business columns.
- Deferred binding: WinterCMS's `deferred_bindings` table holds the uploads and related-record changes of a form whose record is not saved yet. Every operation takes a `lagoon.DeferredKey` (the form's session key, the owning backend admin's id and the master record's morph type from `lagoon.MorphType`) and never reads or changes another admin's rows, since each row stores `backend_user_id`. `lagoon.DeferredBind` and `lagoon.DeferredUnbind` port WinterCMS's duplicate and cancel rules: a repeated bind writes nothing, and an unbind of a slave with a pending bind deletes that bind and returns it so the caller can remove what it created. `lagoon.DeferredBindings` reads and locks a session's bindings for the save that commits them, `lagoon.DeferredForget` deletes them once applied, and `lagoon.DeferredSlaves` is the subquery a list uses to include pending rows. A child created under deferral carries the `lagoon.DeferredEnvelope` (`{"created":true,"pivot":{...},"upload_id":"..."}`) in `pivot_data`. `lagoon.PurgeDeferred` removes expired bindings: it deletes an unattached `system_files` row a bind points at, and its blobs only after the commit, deletes a child only when its binding carries the created envelope, keeps records that were only linked, and locks each batch with `FOR UPDATE SKIP LOCKED`.
- Imports from Laravel: `lagoon.DecryptLaravelPayload` decrypts Laravel `encrypted` payloads with the old application key, for one-off data imports.
- Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), an S3 bucket option behind the `attach.S3BuildTag` build tag (an untagged binary refuses an `s3://` bucket URL at boot with an error naming the tag), public URLs (`attach.PublicURL` for any key, `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since WebP cannot be encoded; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name whose extension follows the sniffed content through `attach.DiskExtension`, so a PNG named `x.html` is stored as `.png`, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming and, in image mode, the `attach.IsAllowedImage` content guard), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`).
- Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), an S3 bucket option behind the `attach.S3BuildTag` build tag (an untagged binary refuses an `s3://` bucket URL at boot with an error naming the tag), public URLs (`attach.PublicURL` for any key, under `storage.uploads.public_url` when a public bucket or CDN serves the files (`attach.PublicURLBase`), `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since WebP cannot be encoded; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name whose extension follows the sniffed content through `attach.DiskExtension`, so a PNG named `x.html` is stored as `.png`, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming and, in image mode, the `attach.IsAllowedImage` content guard), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`).
## Usage
@@ -191,7 +191,8 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `lagoon.RegisterJoinTable` | Registers a custom pivot model for a many-to-many field. |
| `lagoon.DecryptLaravelPayload` | Decrypts a Laravel AES-256-CBC payload for data imports. |
| `attach.File` | The `system_files` row model; `attach.File.URL` is the public URL of the original. |
| `attach.PublicURL` | Public URL of a blob key under `storage.uploads.public_path_prefix`. |
| `attach.PublicURL` | Public URL of a blob key: under `storage.uploads.public_url` when set, else under `storage.uploads.public_path_prefix`. |
| `attach.PublicURLBase` | `storage.uploads.public_url` as recorded by `attach.OpenBucket`, empty when unset. |
| `attach.BrokenImagePNG` | WinterCMS's broken-image picture, stored as the thumbnail of an unusable original. |
| `attach.Owner` | Implemented by models that own attachments; returns the stored morph type name. |
| `attach.OpenBucket` | Opens the uploads bucket from config; refuses `s3://` in a binary built without `attach.S3BuildTag`. |
@@ -231,6 +232,7 @@ Keys are read from the compass config; the `SUMMER_` environment overlay maps a
| `app.previous_keys` | empty | List of earlier base64 keys still accepted when decrypting, for key rotation. |
| `storage.uploads.bucket_url` | none (required by `attach.OpenBucket`) | Uploads bucket URL, `file://`, `mem://` or `s3://` (with `attach.S3BuildTag`; query parameters such as `region`, `endpoint` and `use_path_style` per gocloud.dev, credentials from the AWS environment). |
| `storage.uploads.public_path_prefix` | `/storage/uploads` | URL prefix used when building public file and thumbnail URLs. |
| `storage.uploads.public_url` | empty | Absolute `http` or `https` base of a public bucket or CDN; when set, file and thumbnail URLs are this base plus the partition path. A malformed value fails `attach.OpenBucket`; a set value logs that the bucket must hold only public files. |
| `database.deferred_bindings.purge_days` | `5` | Age in days after which `deferred:purge` removes a deferred binding, WinterCMS's `cleanUp(5)`; the `--days` flag overrides it. |
| `database.deferred_bindings.purge_at` | `03:00` | Daily `HH:MM` (in `app.timezone`) at which the scheduler runs `deferred:purge`; an empty value disables the framework entry. |

View File

@@ -3,6 +3,7 @@ package attach
import (
"context"
"fmt"
"log/slog"
"net/url"
"strings"
"sync"
@@ -24,6 +25,7 @@ const S3BuildTag = "summercms_s3"
var (
prefixMu sync.RWMutex
publicPathPrefix = defaultPublicPathPrefix
publicURLBase string
)
func setPublicPathPrefix(prefix string) {
@@ -32,6 +34,38 @@ func setPublicPathPrefix(prefix string) {
prefixMu.Unlock()
}
func setPublicURLBase(base string) {
prefixMu.Lock()
publicURLBase = base
prefixMu.Unlock()
}
// PublicURLBase is storage.uploads.public_url as recorded by OpenBucket: an
// absolute http or https base (a public bucket or a CDN) that file and
// thumbnail URLs start with instead of PublicPathPrefix. Empty when unset.
func PublicURLBase() string {
prefixMu.RLock()
defer prefixMu.RUnlock()
return publicURLBase
}
// parsePublicURL validates storage.uploads.public_url: empty means unset,
// anything else must be an absolute http or https URL with a host and
// without user info, query or fragment.
func parsePublicURL(raw string) (*url.URL, error) {
value := strings.TrimSpace(raw)
if value == "" {
return nil, nil
}
u, err := url.Parse(value)
bad := err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" ||
(!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https"))
if bad {
return nil, fmt.Errorf("attach: storage.uploads.public_url must be an absolute http or https URL such as https://cdn.example.com/uploads, without user info, query or fragment (set SUMMER_STORAGE__UPLOADS__PUBLIC_URL)")
}
return u, nil
}
// PublicPathPrefix is the URL prefix prepended to partition+filename.
func PublicPathPrefix() string {
prefixMu.RLock()
@@ -79,7 +113,9 @@ func checkS3Driver(bucketURL string) error {
// OpenBucket opens storage.uploads.bucket_url (file://, mem://, or s3:// in a
// binary built with S3BuildTag) and records
// storage.uploads.public_path_prefix. An empty bucket_url fails boot.
// storage.uploads.public_path_prefix and storage.uploads.public_url. An
// empty bucket_url or a malformed public_url fails boot; a set public_url
// logs one info line saying the bucket behind it must hold only public files.
func OpenBucket(ctx context.Context, cfg *compass.Config) (*blob.Bucket, error) {
if cfg == nil {
return nil, fmt.Errorf("attach: config is nil")
@@ -95,6 +131,10 @@ func OpenBucket(ctx context.Context, cfg *compass.Config) (*blob.Bucket, error)
if err := checkS3Driver(bucketURL); err != nil {
return nil, err
}
publicURL, err := parsePublicURL(cfg.String("storage.uploads.public_url"))
if err != nil {
return nil, err
}
bucket, err := blob.OpenBucket(ctx, bucketURL)
if err != nil {
return nil, fmt.Errorf("attach: open bucket %q: %w", raw, err)
@@ -104,6 +144,14 @@ func OpenBucket(ctx context.Context, cfg *compass.Config) (*blob.Bucket, error)
prefix = defaultPublicPathPrefix
}
setPublicPathPrefix(prefix)
if publicURL == nil {
setPublicURLBase("")
return bucket, nil
}
setPublicURLBase(publicURL.String())
slog.Default().InfoContext(ctx, "attach: storage.uploads.public_url is set; every file and thumbnail URL points at this host, "+
"which serves any key without the is_public check, so the bucket behind it must hold only public files",
slog.String("host", publicURL.Host))
return bucket, nil
}

View File

@@ -58,3 +58,49 @@ func TestOpenBucketS3Smoke(t *testing.T) {
t.Fatalf("close: %v", err)
}
}
// TestPublicURLSmoke checks storage.uploads.public_url (D-12): set, URLs
// are the base plus the partition path; unset, they keep the proxy prefix;
// malformed, OpenBucket fails naming the key.
func TestPublicURLSmoke(t *testing.T) {
t.Cleanup(func() {
setPublicPathPrefix(defaultPublicPathPrefix)
setPublicURLBase("")
})
key := BlobKey("651a2b3c4d5e61234567.png")
cfg := openTestConfig(t, "uploads:\n bucket_url: \"mem://\"\n public_url: \"https://cdn.example.com/uploads/\"\n")
bucket, err := OpenBucket(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
_ = bucket.Close()
if got, want := PublicURL(key), "https://cdn.example.com/uploads/"+key; got != want {
t.Fatalf("PublicURL with public_url = %q, want %q", got, want)
}
if got, want := (&File{DiskName: "651a2b3c4d5e61234567.png"}).URL(), "https://cdn.example.com/uploads/651/a2b/3c4/651a2b3c4d5e61234567.png"; got != want {
t.Fatalf("File.URL with public_url = %q, want %q", got, want)
}
cfg = openTestConfig(t, "uploads:\n bucket_url: \"mem://\"\n")
bucket, err = OpenBucket(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
_ = bucket.Close()
if PublicURLBase() != "" || !strings.HasPrefix(PublicURL(key), "/storage/uploads/") {
t.Fatalf("without public_url: base %q, URL %q", PublicURLBase(), PublicURL(key))
}
for _, bad := range []string{"ftp://x", "https://u:p@cdn.example.com", "/storage/uploads", "https://cdn.example.com/?v=1"} {
cfg = openTestConfig(t, "uploads:\n bucket_url: \"mem://\"\n public_url: \""+bad+"\"\n")
bucket, err = OpenBucket(t.Context(), cfg)
if err == nil {
_ = bucket.Close()
t.Fatalf("public_url %q accepted", bad)
}
if !strings.Contains(err.Error(), "storage.uploads.public_url") || strings.Contains(err.Error(), "u:p") {
t.Fatalf("public_url %q: error %q", bad, err)
}
}
}

View File

@@ -76,13 +76,18 @@ func fileExt(diskName string) string {
return strings.ToLower(ext)
}
// PublicURL returns the public URL of a blob key: storage.uploads.
// public_path_prefix and the key joined by exactly one slash. With the
// WinterCMS layout (bucket rooted at storage/app/uploads/public, prefix
// /storage/app/uploads/public) it is Winter's File::getPath() path.
// PublicURL returns the public URL of a blob key. With
// storage.uploads.public_url set (PublicURLBase) it is that base and the key
// joined by exactly one slash. Otherwise it is
// storage.uploads.public_path_prefix and the key joined the same way; with
// the WinterCMS layout (bucket rooted at storage/app/uploads/public, prefix
// /storage/app/uploads/public) that is Winter's File::getPath() path.
func PublicURL(key string) string {
prefix := strings.TrimRight(PublicPathPrefix(), "/")
key = strings.TrimLeft(key, "/")
if base := PublicURLBase(); base != "" {
return strings.TrimRight(base, "/") + "/" + key
}
prefix := strings.TrimRight(PublicPathPrefix(), "/")
if prefix == "" {
return "/" + key
}