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.
This commit is contained in:
Jakub Zych
2026-10-02 15:15:26 +02:00
parent 2f71aeb534
commit 1307060e15
4 changed files with 138 additions and 6 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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")
}
}