Files
summercms/modules/lagoon/attach/url_test.go
Jakub Zych 1307060e15 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.
2026-10-02 15:15:26 +02:00

198 lines
6.6 KiB
Go

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
// and restores the framework default afterwards.
func winterLayoutBucket(t *testing.T) *blob.Bucket {
t.Helper()
dir := t.TempDir()
body := "uploads:\n bucket_url: \"mem://\"\n public_path_prefix: \"/storage/app/uploads/public\"\n"
if err := os.WriteFile(filepath.Join(dir, "storage.yaml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
bucket, err := OpenBucket(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = bucket.Close()
setPublicPathPrefix(defaultPublicPathPrefix)
})
return bucket
}
func TestFileURLWinterLayout(t *testing.T) {
bucket := winterLayoutBucket(t)
// A WinterCMS disk name: uniqid('', true) without the dot, plus the
// extension.
f := &File{ID: 12, DiskName: "651a2b3c4d5e61234567.png"}
if got, want := f.URL(), "/storage/app/uploads/public/651/a2b/3c4/651a2b3c4d5e61234567.png"; got != want {
t.Fatalf("URL = %q, want %q", got, want)
}
if got, want := PublicURL("/651/a2b/3c4/x.png"), "/storage/app/uploads/public/651/a2b/3c4/x.png"; got != want {
t.Fatalf("PublicURL = %q, want %q", got, want)
}
if err := bucket.WriteAll(t.Context(), BlobKey(f.DiskName), testJPEG(t), &blob.WriterOptions{ContentType: "image/png"}); err != nil {
t.Fatal(err)
}
thumb, err := f.Thumb(t.Context(), bucket, 200, 200, "crop")
if err != nil {
t.Fatal(err)
}
// Winter getThumbFilename: implode('_', [thumb, id, w, h, ox, oy, mode.ext]).
if want := "/storage/app/uploads/public/651/a2b/3c4/thumb_12_200_200_0_0_crop.png"; thumb != want {
t.Fatalf("thumb = %q, want %q", thumb, want)
}
var nilFile *File
if nilFile.URL() != "" {
t.Fatal("nil file URL must be empty")
}
}
// webpFixture is a 16x12 lossless WebP: blue left half, red right half.
var webpFixture = []byte{
0x52, 0x49, 0x46, 0x46, 0x2a, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x4c,
0x1d, 0x00, 0x00, 0x00, 0x2f, 0x0f, 0xc0, 0x02, 0x00, 0x0f, 0x70, 0x14, 0xfb, 0x53, 0xd0, 0x5e,
0x88, 0x7b, 0xfe, 0x83, 0x07, 0x62, 0xc1, 0x64, 0xfe, 0xd2, 0xbd, 0x21, 0x44, 0xf4, 0x3f, 0x74,
0x01, 0x00,
}
func TestThumbWebP(t *testing.T) {
cfg, format, err := image.DecodeConfig(bytes.NewReader(webpFixture))
if err != nil {
t.Fatalf("DecodeConfig: %v", err)
}
if format != "webp" || cfg.Width != 16 || cfg.Height != 12 {
t.Fatalf("config = %s %dx%d", format, cfg.Width, cfg.Height)
}
bucket := winterLayoutBucket(t)
f := &File{ID: 5, DiskName: "abcdef123456789012345.webp"}
if err := bucket.WriteAll(t.Context(), BlobKey(f.DiskName), webpFixture, &blob.WriterOptions{ContentType: "image/webp"}); err != nil {
t.Fatal(err)
}
url, err := f.Thumb(t.Context(), bucket, 200, 200, "crop")
if err != nil {
t.Fatalf("Thumb: %v", err)
}
if want := "/storage/app/uploads/public/abc/def/123/thumb_5_200_200_0_0_crop.webp"; url != want {
t.Fatalf("thumb url = %q, want %q", url, want)
}
raw, err := bucket.ReadAll(t.Context(), "abc/def/123/thumb_5_200_200_0_0_crop.webp")
if err != nil {
t.Fatal(err)
}
// imaging cannot encode webp: the thumb is JPEG bytes under the .webp name.
img, err := jpeg.Decode(bytes.NewReader(raw))
if err != nil {
t.Fatalf("thumb is not JPEG: %v", err)
}
if b := img.Bounds(); b.Dx() != 200 || b.Dy() != 200 {
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")
}
}