From 1307060e151220d272cc935faed5bde6dfc0b0be Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 15:15:26 +0200 Subject: [PATCH] fix(12-05): store WinterCMS's broken-image thumbnail for an unusable original (T-12-16) A photo whose original is missing, does not decode or declares more than 4096x4096 pixels made attach.File.Thumb return an error, and every listing that shows the photo answered 500 from then on: one 100-byte PNG uploaded by any household member broke GET collections and the album for everyone. Thumb now follows WinterCMS's File::makeThumb catch branch: it logs the reason at warn level, stores WinterCMS's BrokenImage picture (exported as attach.BrokenImagePNG) under the thumbnail key and returns its URL. Invalid arguments, storage errors and encode failures are still errors. --- docs/database/attachments.md | 2 +- modules/lagoon/README.md | 3 +- modules/lagoon/attach/thumb.go | 49 +++++++++++++++-- modules/lagoon/attach/url_test.go | 90 +++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 6 deletions(-) diff --git a/docs/database/attachments.md b/docs/database/attachments.md index d31306b..9557c45 100644 --- a/docs/database/attachments.md +++ b/docs/database/attachments.md @@ -84,7 +84,7 @@ fmt.Println(url) 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 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. +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. diff --git a/modules/lagoon/README.md b/modules/lagoon/README.md index de60d50..1690065 100644 --- a/modules/lagoon/README.md +++ b/modules/lagoon/README.md @@ -24,7 +24,7 @@ Postgres data layer: the shared GORM connection, per-plugin migrations, model he - Column types: `lagoon.Encrypted` stores AES-256-GCM ciphertext under a key derived from `app.key`, decrypts with previous keys during rotation, and always redacts itself in JSON and string output; `lagoon.Jsonable` stores JSON as TEXT and keeps SQL NULL distinct from an empty value. - 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. - 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`), 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), 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`), 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), 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 @@ -153,6 +153,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration { | `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.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. | | `attach.Publish` | Stores the bucket on the `backpack.App`. | diff --git a/modules/lagoon/attach/thumb.go b/modules/lagoon/attach/thumb.go index 5c9aa5d..c35e9c7 100644 --- a/modules/lagoon/attach/thumb.go +++ b/modules/lagoon/attach/thumb.go @@ -3,18 +3,22 @@ package attach import ( "bytes" "context" + "encoding/base64" + "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" + "log/slog" "path" "regexp" "strings" "github.com/disintegration/imaging" "gocloud.dev/blob" + "gocloud.dev/gcerrors" // The webp decoder lets image.DecodeConfig and File.Thumb read .webp // originals. imaging cannot encode webp, so a webp thumbnail holds JPEG // bytes under the original's .webp name (see defaultEncodeImage). @@ -122,6 +126,13 @@ var encodeImage = defaultEncodeImage // Thumb returns the public URL of a lazily generated thumbnail. The second // call for the same dimensions hits the existing blob and does not resize. +// +// As WinterCMS's File::makeThumb does, an original that is missing from +// the bucket, does not decode, or declares more than 4096 by 4096 pixels +// gets WinterCMS's broken-image picture (BrokenImagePNG) stored as its +// thumbnail, and the failure is logged at warn level instead of returned: +// one unusable upload never fails the listings that show it. An invalid +// mode or size, a storage error and an encode failure are still errors. func (f *File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode string) (string, error) { if f == nil { return "", fmt.Errorf("attach: file is nil") @@ -155,6 +166,9 @@ func (f *File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode st } origKey := part + f.DiskName r, err := bucket.NewReader(ctx, origKey, nil) + if gcerrors.Code(err) == gcerrors.NotFound { + return brokenThumb(ctx, bucket, f, thumbKey, err) + } if err != nil { return "", fmt.Errorf("attach: read original: %w", err) } @@ -171,18 +185,18 @@ func (f *File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode st // allocating it. cfg, _, err := image.DecodeConfig(bytes.NewReader(raw)) if err != nil { - return "", fmt.Errorf("attach: decode original: %w", err) + return brokenThumb(ctx, bucket, f, thumbKey, fmt.Errorf("decode original: %w", err)) } if int64(cfg.Width)*int64(cfg.Height) > maxThumbSourcePixels { - return "", fmt.Errorf("attach: original image is too large") + return brokenThumb(ctx, bucket, f, thumbKey, errOriginalTooLarge) } src, _, err := image.Decode(bytes.NewReader(raw)) if err != nil { - return "", fmt.Errorf("attach: decode original: %w", err) + return brokenThumb(ctx, bucket, f, thumbKey, fmt.Errorf("decode original: %w", err)) } bounds := src.Bounds() if int64(bounds.Dx())*int64(bounds.Dy()) > maxThumbSourcePixels { - return "", fmt.Errorf("attach: original image is too large") + return brokenThumb(ctx, bucket, f, thumbKey, errOriginalTooLarge) } resized := resizeImage(src, w, h, mode) contentType := "image/jpeg" @@ -207,3 +221,30 @@ func (f *File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode st } return PublicURL(thumbKey), nil } + +var errOriginalTooLarge = errors.New("original image is too large") + +// brokenImageBase64 is WinterCMS's Database\Attach\BrokenImage picture, a +// 200x200 PNG, byte for byte. +const brokenImageBase64 = "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" + +// BrokenImagePNG is the PNG Thumb stores as the thumbnail of an unusable +// original: WinterCMS's BrokenImage picture. +var BrokenImagePNG = func() []byte { + b, err := base64.StdEncoding.DecodeString(brokenImageBase64) + if err != nil { + panic(err) + } + return b +}() + +// brokenThumb is the catch branch of WinterCMS's File::makeThumb: log the +// reason, store BrokenImagePNG under the thumbnail key and return its URL. +func brokenThumb(ctx context.Context, bucket *blob.Bucket, f *File, thumbKey string, reason error) (string, error) { + slog.Default().WarnContext(ctx, "attach: thumbnail original is unusable, storing the broken-image picture", + slog.Uint64("file_id", uint64(f.ID)), slog.String("error", reason.Error())) + if err := bucket.WriteAll(ctx, thumbKey, BrokenImagePNG, &blob.WriterOptions{ContentType: "image/png"}); err != nil { + return "", fmt.Errorf("attach: broken-image thumb: %w", err) + } + return PublicURL(thumbKey), nil +} diff --git a/modules/lagoon/attach/url_test.go b/modules/lagoon/attach/url_test.go index 7e77d1e..b8b0ecf 100644 --- a/modules/lagoon/attach/url_test.go +++ b/modules/lagoon/attach/url_test.go @@ -2,14 +2,19 @@ package attach import ( "bytes" + "encoding/binary" + "fmt" + "hash/crc32" "image" "image/jpeg" "os" "path/filepath" "testing" + "time" "git.golem15.com/golem15/summercms/modules/compass" "gocloud.dev/blob" + "gocloud.dev/blob/memblob" ) // winterLayoutBucket opens a mem:// bucket with the WinterCMS public prefix @@ -105,3 +110,88 @@ func TestThumbWebP(t *testing.T) { t.Fatalf("thumb size = %v", b) } } + +// pngHeaderOnly is a PNG holding only a valid IHDR (w x h, 8-bit RGBA) and +// IEND: image.DecodeConfig accepts it, a full decode would allocate w*h*4 +// bytes. +func pngHeaderOnly(w, h uint32) []byte { + var buf bytes.Buffer + buf.WriteString("\x89PNG\r\n\x1a\n") + chunk := func(typ string, data []byte) { + _ = binary.Write(&buf, binary.BigEndian, uint32(len(data))) + buf.WriteString(typ) + buf.Write(data) + sum := crc32.NewIEEE() + sum.Write([]byte(typ)) + sum.Write(data) + _ = binary.Write(&buf, binary.BigEndian, sum.Sum32()) + } + ihdr := make([]byte, 13) + binary.BigEndian.PutUint32(ihdr[0:], w) + binary.BigEndian.PutUint32(ihdr[4:], h) + ihdr[8], ihdr[9] = 8, 6 + chunk("IHDR", ihdr) + chunk("IEND", nil) + return buf.Bytes() +} + +// TestThumbBrokenSourceServesPlaceholder: as WinterCMS's File::makeThumb +// does, an original that is missing, does not decode, or declares more +// pixels than the thumbnailer accepts gets WinterCMS's 200x200 broken-image +// picture as its thumbnail instead of an error, so one bad upload can never +// make every later listing fail (T-12-16). The placeholder is stored under +// the thumbnail key and reused. Invalid arguments are still errors. +func TestThumbBrokenSourceServesPlaceholder(t *testing.T) { + ctx := t.Context() + bucket := memblob.OpenBucket(nil) + t.Cleanup(func() { _ = bucket.Close() }) + cases := []struct { + name string + original []byte // nil: no blob at all + }{ + {"missing", nil}, + {"undecodable", []byte("plain text, not an image")}, + {"huge-canvas", pngHeaderOnly(30000, 30000)}, + } + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := &File{ID: uint(100 + i), DiskName: fmt.Sprintf("abc%03ddefghij.png", i)} + if tc.original != nil { + if err := bucket.WriteAll(ctx, BlobKey(f.DiskName), tc.original, nil); err != nil { + t.Fatal(err) + } + } + start := time.Now() + url, err := f.Thumb(ctx, bucket, 200, 200, "crop") + if err != nil { + t.Fatalf("Thumb: %v", err) + } + if time.Since(start) > 2*time.Second { + t.Fatalf("Thumb took %s", time.Since(start)) + } + key := PartitionDirectory(f.DiskName) + ThumbFilename(f.ID, 200, 200, 0, 0, "crop", "png") + if url != PublicURL(key) { + t.Fatalf("url %q, want %q", url, PublicURL(key)) + } + stored, err := bucket.ReadAll(ctx, key) + if err != nil { + t.Fatal(err) + } + cfg, format, err := image.DecodeConfig(bytes.NewReader(stored)) + if err != nil || format != "png" || cfg.Width != 200 || cfg.Height != 200 { + t.Fatalf("placeholder is not the 200x200 PNG: %s %dx%d %v", format, cfg.Width, cfg.Height, err) + } + again, err := f.Thumb(ctx, bucket, 200, 200, "crop") + if err != nil || again != url { + t.Fatalf("second call: %q %v", again, err) + } + }) + } + f := &File{ID: 1, DiskName: "abcdefghijkl.png"} + if _, err := f.Thumb(ctx, bucket, 200, 200, "../x"); err == nil { + t.Fatal("an invalid mode must stay an error") + } + if _, err := f.Thumb(ctx, bucket, 5000, 200, "crop"); err == nil { + t.Fatal("an out-of-range size must stay an error") + } +}