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.
198 lines
6.6 KiB
Go
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")
|
|
}
|
|
}
|