package attach import ( "bytes" "encoding/binary" "fmt" "hash/crc32" "image" "image/gif" "image/jpeg" "image/png" "os" "path/filepath" "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/backpack" "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") } } // TestThumbModesAndFormats generates crop, exact, auto and fit thumbnails // from PNG, GIF and JPEG originals and checks the stored bytes decode to // the requested box in the original's format. func TestThumbModesAndFormats(t *testing.T) { ctx := t.Context() bucket := memblob.OpenBucket(nil) t.Cleanup(func() { _ = bucket.Close() }) src := image.NewRGBA(image.Rect(0, 0, 120, 60)) encoders := map[string]func(*bytes.Buffer) error{ "png": func(b *bytes.Buffer) error { return png.Encode(b, src) }, "gif": func(b *bytes.Buffer) error { return gif.Encode(b, src, nil) }, "jpg": func(b *bytes.Buffer) error { return jpeg.Encode(b, src, nil) }, } id := uint(500) for ext, enc := range encoders { var buf bytes.Buffer if err := enc(&buf); err != nil { t.Fatal(err) } for _, mode := range []string{"crop", "exact", "auto", "fit", "CROP"} { id++ f := &File{ID: id, DiskName: fmt.Sprintf("m%02dmodes%03d.%s", id%100, id, ext)} if err := bucket.WriteAll(ctx, BlobKey(f.DiskName), buf.Bytes(), nil); err != nil { t.Fatal(err) } url, err := f.Thumb(ctx, bucket, 40, 40, mode) if err != nil { t.Fatalf("%s %s: %v", ext, mode, err) } key := strings.TrimPrefix(url, PublicPathPrefix()+"/") raw, err := bucket.ReadAll(ctx, key) if err != nil { t.Fatal(err) } cfg, format, err := image.DecodeConfig(bytes.NewReader(raw)) if err != nil { t.Fatal(err) } wantFormat := map[string]string{"png": "png", "gif": "gif", "jpg": "jpeg"}[ext] if format != wantFormat { t.Errorf("%s %s: thumbnail format %s", ext, mode, format) } switch strings.ToLower(mode) { case "crop", "exact": if cfg.Width != 40 || cfg.Height != 40 { t.Errorf("%s %s: %dx%d, want 40x40", ext, mode, cfg.Width, cfg.Height) } default: if cfg.Width != 40 || cfg.Height != 20 { t.Errorf("%s %s: %dx%d, want 40x20 (fit)", ext, mode, cfg.Width, cfg.Height) } } } } f := &File{ID: 1, DiskName: "noextension"} if got := fileExt(f.DiskName); got != "jpg" { t.Fatalf("fileExt without extension = %q", got) } if got := fileExt("A.PNG"); got != "png" { t.Fatalf("fileExt upper case = %q", got) } if _, err := (*File)(nil).Thumb(ctx, bucket, 10, 10, "crop"); err == nil { t.Fatal("nil file") } if _, err := f.Thumb(ctx, nil, 10, 10, "crop"); err == nil { t.Fatal("nil bucket") } if _, err := (&File{ID: 2, DiskName: "abcdefghi.p-ng"}).Thumb(ctx, bucket, 10, 10, "crop"); err == nil { t.Fatal("unsafe extension") } } // TestBucketAndURLEdges covers bucket URL normalisation, OpenBucket and // Publish refusals, partition and URL helpers and static prefix stripping. func TestBucketAndURLEdges(t *testing.T) { for raw, want := range map[string]string{ "mem://": "mem://", "memory://anything": "mem://", "fileblob:///tmp/x": "file:///tmp/x?create_dir=true", "file:///tmp/y": "file:///tmp/y?create_dir=true", "file:///tmp/z?create_dir=false": "file:///tmp/z?create_dir=false", "s3://bucket?region=eu-central-1": "s3://bucket?region=eu-central-1", } { got, err := normalizeBucketURL(raw) if err != nil || got != want { t.Errorf("normalizeBucketURL(%q) = %q %v, want %q", raw, got, err, want) } } if _, err := normalizeBucketURL("%zz"); err == nil { t.Error("an unparsable bucket URL must be an error") } if _, err := OpenBucket(t.Context(), nil); err == nil { t.Error("nil config") } open := func(body string) error { dir := t.TempDir() 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) } b, err := OpenBucket(t.Context(), cfg) if err == nil { _ = b.Close() } return err } if err := open("uploads:\n bucket_url: \"\"\n"); err == nil { t.Error("an empty bucket_url must fail") } if err := open("uploads:\n bucket_url: \"nope://x\"\n"); err == nil { t.Error("an unknown scheme must fail") } dir := t.TempDir() if err := open("uploads:\n bucket_url: \"file://" + dir + "/up\"\n"); err != nil { t.Fatalf("file bucket: %v", err) } t.Cleanup(func() { setPublicPathPrefix(defaultPublicPathPrefix) }) if got := PublicPathPrefix(); got != defaultPublicPathPrefix { t.Fatalf("default prefix = %q", got) } if err := Publish(nil, memblob.OpenBucket(nil)); err == nil { t.Error("Publish with a nil app") } if err := Publish(backpack.New(nil), nil); err == nil { t.Error("Publish with a nil bucket") } if got := PartitionDirectory("ab"); got != "ab/" { t.Errorf("short partition = %q", got) } if got := PartitionDirectory("abcdefghijkl"); got != "abc/def/ghi/" { t.Errorf("partition = %q", got) } setPublicPathPrefix("") if got := PublicURL("/a/b.png"); got != "/a/b.png" { t.Errorf("PublicURL without prefix = %q", got) } setPublicPathPrefix("/files/") if got := PublicURL("a/b.png"); got != "/files/a/b.png" { t.Errorf("PublicURL with a trailing slash prefix = %q", got) } setPublicPathPrefix(defaultPublicPathPrefix) if (*File)(nil).URL() != "" { t.Error("nil File URL") } for _, tc := range []struct { path, prefix, key string ok bool }{ {"/abc/def.png", "", "abc/def.png", true}, {"/storage", "/storage", "", false}, {"/storage/a.png", "/storage", "a.png", true}, {"/storagex/a.png", "/storage", "", false}, } { key, ok := stripStaticPrefix(tc.path, tc.prefix) if key != tc.key || ok != tc.ok { t.Errorf("stripStaticPrefix(%q, %q) = %q %v", tc.path, tc.prefix, key, ok) } } }