Files

295 lines
8.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package media
import (
"bytes"
"encoding/binary"
"hash/crc32"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"strings"
"testing"
xwebp "golang.org/x/image/webp"
)
// photo makes a JPEG w×h whose left half is red and right half blue, so its
// orientation can be read back from the pixels.
func photo(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
c := color.RGBA{220, 20, 20, 255}
if x >= w/2 {
c = color.RGBA{20, 20, 220, 255}
}
img.Set(x, y, c)
}
}
var b bytes.Buffer
if err := jpeg.Encode(&b, img, &jpeg.Options{Quality: 95}); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
// withEXIF puts an EXIF block (orientation, and a GPS block with a latitude)
// and a comment right after a JPEG's start marker, the way a phone does.
func withEXIF(jpg []byte, orientation uint16, gps bool) []byte {
bo := binary.LittleEndian
var t bytes.Buffer
t.WriteString("II")
binary.Write(&t, bo, uint16(42))
binary.Write(&t, bo, uint32(8))
n := uint16(1)
if gps {
n = 2
}
binary.Write(&t, bo, n)
// Orientation: tag, type SHORT, count 1, value.
binary.Write(&t, bo, []uint16{0x0112, 3})
binary.Write(&t, bo, uint32(1))
binary.Write(&t, bo, []uint16{orientation, 0})
if gps {
gpsOff := uint32(8 + 2 + 2*12 + 4)
binary.Write(&t, bo, []uint16{0x8825, 4})
binary.Write(&t, bo, uint32(1))
binary.Write(&t, bo, gpsOff)
}
binary.Write(&t, bo, uint32(0)) // no next IFD
if gps {
binary.Write(&t, bo, uint16(1))
binary.Write(&t, bo, []uint16{0x0001, 2}) // GPSLatitudeRef "N"
binary.Write(&t, bo, uint32(2))
t.Write([]byte{'N', 0, 0, 0})
binary.Write(&t, bo, uint32(0))
}
seg := append([]byte("Exif\x00\x00"), t.Bytes()...)
var out bytes.Buffer
out.Write(jpg[:2])
out.Write([]byte{0xFF, 0xE1})
binary.Write(&out, binary.BigEndian, uint16(len(seg)+2))
out.Write(seg)
com := []byte("secret comment")
out.Write([]byte{0xFF, 0xFE})
binary.Write(&out, binary.BigEndian, uint16(len(com)+2))
out.Write(com)
out.Write(jpg[2:])
return out.Bytes()
}
func none(string) bool { return false }
// sizes are the JPEG or PNG files, without their WebP copies.
func sizes(img *Image) []File {
var out []File
for _, f := range img.Files {
if !f.WebP {
out = append(out, f)
}
}
return out
}
func decodeAny(t *testing.T, f File) image.Image {
t.Helper()
var (
got image.Image
err error
)
switch {
case f.WebP:
got, err = xwebp.Decode(bytes.NewReader(f.Data))
case strings.HasSuffix(f.Path, ".png"):
got, err = png.Decode(bytes.NewReader(f.Data))
default:
got, err = jpeg.Decode(bytes.NewReader(f.Data))
}
if err != nil {
t.Fatalf("%s: %v", f.Path, err)
}
return got
}
func TestReadJPEGMeta(t *testing.T) {
m := ReadJPEGMeta(withEXIF(photo(t, 40, 20), 6, true))
if m.Orientation != 6 || !m.GPS || !m.EXIF {
t.Fatalf("got %+v", m)
}
m = ReadJPEGMeta(photo(t, 40, 20))
if m.Orientation != 1 || m.GPS || m.EXIF {
t.Fatalf("plain JPEG: got %+v", m)
}
if m := ReadJPEGMeta([]byte{0xFF, 0xD8, 0xFF, 0xE1, 0xFF}); m.Orientation != 1 {
t.Fatalf("truncated: got %+v", m)
}
}
func TestStripsAndOrients(t *testing.T) {
in := withEXIF(photo(t, 2400, 1200), 6, true) // stored on its side
img, err := Process(in, "Dock at Dawn.JPG", "2026/10", none)
if err != nil {
t.Fatal(err)
}
if img.URL != "/media/2026/10/dock-at-dawn.jpg" || img.Type != "image/jpeg" {
t.Fatalf("url %s type %s", img.URL, img.Type)
}
// Shown the right way up it's 1200 wide and 2400 tall: 1200 is under the
// largest width, so that's the main size, with 960 and 480 beside it.
if img.Width != 1200 || img.Height != 2400 || len(sizes(img)) != 3 {
t.Fatalf("main %dx%d, %d sizes", img.Width, img.Height, len(sizes(img)))
}
paths := map[string]bool{}
for _, f := range img.Files {
paths[f.Path] = true
}
for _, want := range []string{"dock-at-dawn.jpg", "dock-at-dawn-480w.jpg", "dock-at-dawn-960w.jpg", "dock-at-dawn.webp", "dock-at-dawn-480w.webp"} {
if !paths["media/2026/10/"+want] {
t.Fatalf("no %s in %v", want, paths)
}
}
for _, f := range img.Files {
m := ReadJPEGMeta(f.Data)
if m.EXIF || m.GPS || bytes.Contains(f.Data, []byte("secret comment")) || bytes.Contains(f.Data, []byte("EXIF")) {
t.Fatalf("%s still carries metadata", f.Path)
}
got := decodeAny(t, f)
if got.Bounds().Dx() != f.Width || got.Bounds().Dy() != f.Height {
t.Fatalf("%s is %v, says %dx%d", f.Path, got.Bounds(), f.Width, f.Height)
}
// Red was on the left of the stored picture; a quarter turn clockwise
// puts it on top.
r, _, b, _ := got.At(f.Width/2, f.Height/8).RGBA()
if r < b {
t.Fatalf("%s: top isn't red, so it wasn't turned", f.Path)
}
}
notes := strings.Join(img.Notes, " ")
for _, want := range []string{"location", "EXIF", "right way up"} {
if !strings.Contains(notes, want) {
t.Errorf("notes %q don't mention %q", notes, want)
}
}
}
func TestResizesLargePhoto(t *testing.T) {
img, err := Process(photo(t, 4000, 3000), "big.jpg", "2026/10", none)
if err != nil {
t.Fatal(err)
}
var widths []int
for _, f := range sizes(img) {
widths = append(widths, f.Width)
}
if len(widths) != 3 || widths[0] != 1600 || img.Height != 1200 {
t.Fatalf("widths %v, main height %d", widths, img.Height)
}
}
func TestNeverOverwrites(t *testing.T) {
taken := func(p string) bool { return p == "static/media/2026/10/a.jpg" || p == "static/media/2026/10/a-2.jpg" }
img, err := Process(photo(t, 100, 50), "a.jpg", "2026/10", taken)
if err != nil {
t.Fatal(err)
}
if img.URL != "/media/2026/10/a-3.jpg" || len(sizes(img)) != 1 {
t.Fatalf("got %s with %d files", img.URL, len(img.Files))
}
}
func TestPNGStaysPNGWithTransparency(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 64, 32))
src.Set(1, 1, color.NRGBA{0, 0, 0, 255})
var b bytes.Buffer
png.Encode(&b, src)
b.WriteString("trailing payload after IEND")
img, err := Process(b.Bytes(), "logo.png", "2026/10", none)
if err != nil {
t.Fatal(err)
}
if img.Type != "image/png" || !strings.HasSuffix(img.URL, ".png") {
t.Fatalf("got %s %s", img.Type, img.URL)
}
if bytes.Contains(img.Files[0].Data, []byte("trailing payload")) {
t.Fatal("data after the image survived")
}
for _, f := range img.Files {
if _, _, _, a := decodeAny(t, f).At(10, 10).RGBA(); a != 0 {
t.Fatalf("%s: transparency lost", f.Path)
}
}
}
func TestAnimatedGIFNoted(t *testing.T) {
pal := color.Palette{color.Black, color.White}
a := &gif.GIF{}
for i := 0; i < 3; i++ {
a.Image = append(a.Image, image.NewPaletted(image.Rect(0, 0, 10, 10), pal))
a.Delay = append(a.Delay, 10)
}
var b bytes.Buffer
gif.EncodeAll(&b, a)
if n := gifFrames(b.Bytes()); n != 3 {
t.Fatalf("counted %d frames", n)
}
img, err := Process(b.Bytes(), "spin.gif", "2026/10", none)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(img.Notes, " "), "first frame") {
t.Fatalf("notes: %v", img.Notes)
}
}
func TestRefuses(t *testing.T) {
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
if _, err := Process(svg, "x.svg", "2026/10", none); err != ErrNotPicture {
t.Fatalf("svg: %v", err)
}
if _, err := Process([]byte("<html><script>"), "x.jpg", "2026/10", none); err != ErrNotPicture {
t.Fatalf("html named .jpg: %v", err)
}
// A PNG header claiming 100000×100000 pixels is refused before decoding.
var b bytes.Buffer
png.Encode(&b, image.NewGray(image.Rect(0, 0, 1, 1)))
h := b.Bytes()
binary.BigEndian.PutUint32(h[16:], 100000)
binary.BigEndian.PutUint32(h[20:], 100000)
binary.BigEndian.PutUint32(h[29:], crc32.ChecksumIEEE(h[12:29]))
if _, err := Process(h, "bomb.png", "2026/10", none); err == nil || !strings.Contains(err.Error(), "megapixels") {
t.Fatalf("bomb: %v", err)
}
}
func TestWebPSmallerAndRight(t *testing.T) {
img, err := Process(photo(t, 1600, 1000), "p.jpg", "2026/10", none)
if err != nil {
t.Fatal(err)
}
var jpg, webp *File
for i := range img.Files {
switch img.Files[i].Path {
case "media/2026/10/p.jpg":
jpg = &img.Files[i]
case "media/2026/10/p.webp":
webp = &img.Files[i]
}
}
if jpg == nil || webp == nil {
t.Fatal("missing the main JPEG or its WebP")
}
if len(webp.Data) >= len(jpg.Data) {
t.Fatalf("WebP %d bytes, JPEG %d", len(webp.Data), len(jpg.Data))
}
got := decodeAny(t, *webp)
if got.Bounds().Dx() != 1600 || got.Bounds().Dy() != 1000 {
t.Fatalf("WebP is %v", got.Bounds())
}
if r, _, b, _ := got.At(100, 500).RGBA(); r < b {
t.Fatal("WebP colors are wrong")
}
}