diff --git a/docs/database/attachments.md b/docs/database/attachments.md index 6478949..c166f0a 100644 --- a/docs/database/attachments.md +++ b/docs/database/attachments.md @@ -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. diff --git a/docs/services/storage.md b/docs/services/storage.md index 6c40a2b..17805f8 100644 --- a/docs/services/storage.md +++ b/docs/services/storage.md @@ -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 /{path...}` on `attach.StaticHandlerPublic`: diff --git a/docs/setup/configuration.md b/docs/setup/configuration.md index d2beec0..79c8a49 100644 --- a/docs/setup/configuration.md +++ b/docs/setup/configuration.md @@ -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) | diff --git a/modules/lagoon/README.md b/modules/lagoon/README.md index 7f1e0c4..797c348 100644 --- a/modules/lagoon/README.md +++ b/modules/lagoon/README.md @@ -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. | diff --git a/modules/lagoon/attach/bucket.go b/modules/lagoon/attach/bucket.go index 8476aa2..bc19bf8 100644 --- a/modules/lagoon/attach/bucket.go +++ b/modules/lagoon/attach/bucket.go @@ -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 } diff --git a/modules/lagoon/attach/infra_smoke_test.go b/modules/lagoon/attach/infra_smoke_test.go index 1f3919c..2d240e3 100644 --- a/modules/lagoon/attach/infra_smoke_test.go +++ b/modules/lagoon/attach/infra_smoke_test.go @@ -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) + } + } +} diff --git a/modules/lagoon/attach/thumb.go b/modules/lagoon/attach/thumb.go index 265c465..132a199 100644 --- a/modules/lagoon/attach/thumb.go +++ b/modules/lagoon/attach/thumb.go @@ -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 }