package mediaapi import ( "bytes" "context" "errors" "image" "image/color" "image/gif" "image/jpeg" "image/png" "io" "log/slog" "net/http" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "github.com/dariuszjaniak/warp/server/internal/config" "github.com/dariuszjaniak/warp/server/internal/federation" "github.com/dariuszjaniak/warp/server/internal/storage" _ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" "github.com/dariuszjaniak/warp/server/internal/storage/filestore" ) const ( serverName = "warp.example" alice = "@alice:warp.example" bob = "@bob:warp.example" ) // fakeRemote stands in for the federation client. type fakeRemote struct { mu sync.Mutex media map[string]federation.MediaResponse content map[string][]byte thumbs map[string][]byte err error downloads atomic.Int32 thumbAsks atomic.Int32 // gate, when set, holds every download until it is closed. gate chan struct{} } func (f *fakeRemote) DownloadMedia(_ context.Context, origin, mediaID string, _ time.Duration) (federation.MediaResponse, error) { f.downloads.Add(1) if f.gate != nil { <-f.gate } if f.err != nil { return federation.MediaResponse{}, f.err } f.mu.Lock() defer f.mu.Unlock() resp, ok := f.media[origin+"/"+mediaID] if !ok { return federation.MediaResponse{}, &federation.HTTPError{Status: http.StatusNotFound, Code: "M_NOT_FOUND"} } resp.Body = io.NopCloser(bytes.NewReader(f.content[origin+"/"+mediaID])) return resp, nil } func (f *fakeRemote) MediaThumbnail(_ context.Context, origin, mediaID string, req federation.ThumbnailRequest, _ time.Duration) (federation.MediaResponse, error) { f.thumbAsks.Add(1) f.mu.Lock() defer f.mu.Unlock() data, ok := f.thumbs[origin+"/"+mediaID] if !ok { return federation.MediaResponse{}, &federation.HTTPError{Status: http.StatusNotFound, Code: "M_NOT_FOUND"} } return federation.MediaResponse{ContentType: "image/png", Body: io.NopCloser(bytes.NewReader(data))}, nil } type testAPI struct { *API store storage.MediaStore remote *fakeRemote now *atomic.Int64 } func newTestAPI(t *testing.T, tweak func(*config.Media)) *testAPI { t.Helper() dir := t.TempDir() db, err := storage.Open(t.Context(), storage.Config{ Driver: storage.DriverSQLite, ConnectionString: "file:" + filepath.ToSlash(filepath.Join(dir, "warp.db")) + "?_pragma=busy_timeout(5000)", MaxOpenConns: 4, }, slog.New(slog.DiscardHandler)) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) if err := db.Migrate(t.Context()); err != nil { t.Fatal(err) } blobs, err := filestore.New(filepath.Join(dir, "media")) if err != nil { t.Fatal(err) } cfg := config.Default().Media if tweak != nil { tweak(&cfg) } now := &atomic.Int64{} now.Store(time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC).UnixMilli()) remote := &fakeRemote{media: map[string]federation.MediaResponse{}, content: map[string][]byte{}, thumbs: map[string][]byte{}} api, err := New(Options{ ServerName: serverName, Store: db.Media(), Blobs: blobs, Remote: remote, Config: cfg, Logger: slog.New(slog.DiscardHandler), Now: func() time.Time { return time.UnixMilli(now.Load()) }, }) if err != nil { t.Fatal(err) } return &testAPI{API: api, store: db.Media(), remote: remote, now: now} } func readAll(t *testing.T, c Content) []byte { t.Helper() defer func() { _ = c.Body.Close() }() data, err := io.ReadAll(c.Body) if err != nil { t.Fatal(err) } return data } func mediaIDOf(t *testing.T, mxc string) string { t.Helper() rest, ok := strings.CutPrefix(mxc, "mxc://"+serverName+"/") if !ok || !ValidMediaID(rest) { t.Fatalf("bad mxc URI %q", mxc) } return rest } func TestUploadAndDownload(t *testing.T) { api := newTestAPI(t, nil) ctx := t.Context() mxc, err := api.Upload(ctx, Upload{ UserID: alice, ContentType: "text/plain; charset=utf-8", FileName: "hello.txt", Body: strings.NewReader("hello, world"), }) if err != nil { t.Fatalf("Upload: %v", err) } id := mediaIDOf(t, mxc) c, err := api.Download(ctx, serverName, id, FetchOptions{}) if err != nil { t.Fatalf("Download: %v", err) } if got := readAll(t, c); string(got) != "hello, world" { t.Errorf("content = %q", got) } if c.ContentType != "text/plain; charset=utf-8" || c.FileName != "hello.txt" || c.Size != 12 || len(c.SHA256) != 64 { t.Errorf("content metadata = %+v", c) } for _, bad := range []struct{ origin, id string }{ {serverName, "nope"}, {serverName, "../etc"}, {"bad/server", id}, } { if _, err := api.Download(ctx, bad.origin, bad.id, FetchOptions{}); !errors.Is(err, ErrNotFound) { t.Errorf("Download(%s, %s) = %v, want ErrNotFound", bad.origin, bad.id, err) } } } func TestUploadNormalisesContentType(t *testing.T) { for raw, want := range map[string]string{ "": "application/octet-stream", "not a type;;": "application/octet-stream", "Image/PNG": "image/png", "text/plain; charset=x": "text/plain; charset=x", } { if got := normaliseContentType(raw); got != want { t.Errorf("normaliseContentType(%q) = %q, want %q", raw, got, want) } } } func TestUploadTooLarge(t *testing.T) { api := newTestAPI(t, func(m *config.Media) { m.MaxUploadBytes = 10 }) _, err := api.Upload(t.Context(), Upload{UserID: alice, Body: strings.NewReader("eleven bytes")}) if !errors.Is(err, ErrTooLarge) { t.Fatalf("Upload = %v, want ErrTooLarge", err) } } func TestMediaIDs(t *testing.T) { seen := map[string]bool{} for range 1000 { id := newMediaID() if len(id) != mediaIDLength || !ValidMediaID(id) { t.Fatalf("bad media ID %q", id) } if seen[id] { t.Fatalf("media ID %q generated twice", id) } seen[id] = true } } func TestCreateAndUploadTo(t *testing.T) { api := newTestAPI(t, func(m *config.Media) { m.MaxPendingUploads = 2 }) ctx := t.Context() mxc, expires, err := api.Create(ctx, alice) if err != nil { t.Fatalf("Create: %v", err) } if want := api.now.Load() + (24 * time.Hour).Milliseconds(); expires != want { t.Errorf("expires = %d, want %d", expires, want) } id := mediaIDOf(t, mxc) // Not there yet: a download that does not wait is told so. if _, err := api.Download(ctx, serverName, id, FetchOptions{}); !errors.Is(err, ErrNotYetUploaded) { t.Errorf("Download of pending media = %v, want ErrNotYetUploaded", err) } up := func(user, body string) error { return api.UploadTo(ctx, serverName, id, Upload{UserID: user, ContentType: "text/plain", Body: strings.NewReader(body)}) } if err := up(bob, "bob's"); !errors.Is(err, ErrNotYours) { t.Errorf("UploadTo by another user = %v, want ErrNotYours", err) } if err := up(alice, "alice's"); err != nil { t.Fatalf("UploadTo: %v", err) } if err := up(alice, "again"); !errors.Is(err, ErrCannotOverwrite) { t.Errorf("second UploadTo = %v, want ErrCannotOverwrite", err) } c, err := api.Download(ctx, serverName, id, FetchOptions{}) if err != nil { t.Fatalf("Download: %v", err) } if got := readAll(t, c); string(got) != "alice's" { t.Errorf("content = %q", got) } // The limit counts reservations without content. if _, _, err := api.Create(ctx, alice); err != nil { t.Fatal(err) } if _, _, err := api.Create(ctx, alice); err != nil { t.Fatal(err) } if _, _, err := api.Create(ctx, alice); !errors.Is(err, ErrTooManyPending) { t.Errorf("third pending Create = %v, want ErrTooManyPending", err) } if _, _, err := api.Create(ctx, bob); err != nil { t.Errorf("another user's Create: %v", err) } // A day later the reservations have lapsed: they no longer count, // and their IDs no longer take content. lapsed, _, _ := api.Create(ctx, bob) api.now.Add((25 * time.Hour).Milliseconds()) err = api.UploadTo(ctx, serverName, mediaIDOf(t, lapsed), Upload{UserID: bob, Body: strings.NewReader("late")}) if !errors.Is(err, ErrNotFound) { t.Errorf("UploadTo a lapsed ID = %v, want ErrNotFound", err) } if _, _, err := api.Create(ctx, alice); err != nil { t.Errorf("Create after the reservations lapsed: %v", err) } } func TestDownloadWaitsForPendingContent(t *testing.T) { api := newTestAPI(t, nil) ctx := t.Context() // The wait uses the real clock for its timer; only the deadline is // computed from the API's clock, which this test leaves running. api.API.now = time.Now mxc, _, err := api.Create(ctx, alice) if err != nil { t.Fatal(err) } id := mediaIDOf(t, mxc) done := make(chan error, 1) go func() { c, err := api.Download(ctx, serverName, id, FetchOptions{Wait: 10 * time.Second}) if err == nil { _ = c.Body.Close() } done <- err }() time.Sleep(50 * time.Millisecond) if err := api.UploadTo(ctx, serverName, id, Upload{UserID: alice, Body: strings.NewReader("now")}); err != nil { t.Fatal(err) } select { case err := <-done: if err != nil { t.Fatalf("waiting Download = %v", err) } case <-time.After(5 * time.Second): t.Fatal("the upload did not wake the waiting download") } // A wait that runs out is ErrNotYetUploaded. other, _, _ := api.Create(ctx, alice) start := time.Now() _, err = api.Download(ctx, serverName, mediaIDOf(t, other), FetchOptions{Wait: 100 * time.Millisecond}) if !errors.Is(err, ErrNotYetUploaded) { t.Errorf("Download after the wait = %v, want ErrNotYetUploaded", err) } if time.Since(start) < 100*time.Millisecond { t.Error("Download did not wait") } } func TestRemoteMediaIsFetchedOnceAndCached(t *testing.T) { api := newTestAPI(t, nil) ctx := t.Context() api.remote.media["remote.example/abc"] = federation.MediaResponse{ ContentType: "image/png", ContentDisposition: `inline; filename="dir/cat.png"`, } api.remote.content["remote.example/abc"] = []byte("png bytes") api.remote.gate = make(chan struct{}) const callers = 5 var wg sync.WaitGroup errs := make(chan error, callers) for range callers { wg.Go(func() { c, err := api.Download(ctx, "remote.example", "abc", FetchOptions{}) if err != nil { errs <- err return } if got := readAll(t, c); string(got) != "png bytes" { errs <- errors.New("wrong content: " + string(got)) } if c.FileName != "cat.png" { errs <- errors.New("file name kept its directory: " + c.FileName) } }) } time.Sleep(50 * time.Millisecond) close(api.remote.gate) wg.Wait() close(errs) for err := range errs { t.Error(err) } if n := api.remote.downloads.Load(); n != 1 { t.Errorf("%d downloads for %d concurrent requests, want 1", n, callers) } // Cached: served without asking again, even with federation off. api.remote.err = federation.ErrDisabled c, err := api.Download(ctx, "remote.example", "abc", FetchOptions{}) if err != nil { t.Fatalf("cached Download: %v", err) } _ = c.Body.Close() if n := api.remote.downloads.Load(); n != 1 { t.Errorf("cached media was fetched again (%d downloads)", n) } // Another server asking for it is not served a third server's media. if _, err := api.Download(ctx, "remote.example", "abc", FetchOptions{LocalOnly: true}); !errors.Is(err, ErrNotFound) { t.Errorf("LocalOnly Download of remote media = %v, want ErrNotFound", err) } } func TestRemoteErrors(t *testing.T) { for name, tc := range map[string]struct { err error want error }{ "federation off": {federation.ErrDisabled, ErrFederationDisabled}, "not allowed": {federation.ErrNotAllowed, ErrFederationDisabled}, "not found": {&federation.HTTPError{Status: 404, Code: "M_NOT_FOUND"}, ErrNotFound}, "not uploaded": {&federation.HTTPError{Status: 504, Code: "M_NOT_YET_UPLOADED"}, ErrNotYetUploaded}, "too large": {&federation.HTTPError{Status: 413, Code: "M_TOO_LARGE"}, ErrTooLarge}, "server error": {&federation.HTTPError{Status: 500}, ErrRemote}, "network failure": {errors.New("connection refused"), ErrRemote}, } { t.Run(name, func(t *testing.T) { api := newTestAPI(t, nil) api.remote.err = tc.err _, err := api.Download(t.Context(), "remote.example", "abc", FetchOptions{}) if !errors.Is(err, tc.want) { t.Errorf("Download = %v, want %v", err, tc.want) } }) } api := newTestAPI(t, nil) api.remote.content["remote.example/big"] = bytes.Repeat([]byte("x"), 100) api.remote.media["remote.example/big"] = federation.MediaResponse{ContentType: "text/plain"} api.cfg.MaxUploadBytes = 10 if _, err := api.Download(t.Context(), "remote.example", "big", FetchOptions{}); !errors.Is(err, ErrTooLarge) { t.Errorf("Download of oversized remote media = %v, want ErrTooLarge", err) } } // --- thumbnails --------------------------------------------------------------- func testImage(w, h int) *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, w, h)) for y := range h { for x := range w { img.Set(x, y, color.RGBA{uint8(x), uint8(y), 128, 255}) } } return img } func encodePNG(t *testing.T, img image.Image) []byte { t.Helper() var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { t.Fatal(err) } return buf.Bytes() } func uploadBytes(t *testing.T, api *testAPI, contentType string, data []byte) string { t.Helper() mxc, err := api.Upload(t.Context(), Upload{UserID: alice, ContentType: contentType, Body: bytes.NewReader(data)}) if err != nil { t.Fatal(err) } return mediaIDOf(t, mxc) } func decodeThumbnail(t *testing.T, c Content) image.Image { t.Helper() img, _, err := image.Decode(bytes.NewReader(readAll(t, c))) if err != nil { t.Fatalf("thumbnail does not decode: %v", err) } return img } func TestThumbnails(t *testing.T) { api := newTestAPI(t, nil) ctx := t.Context() id := uploadBytes(t, api, "image/png", encodePNG(t, testImage(1000, 500))) for _, tc := range []struct { req ThumbnailRequest wantW, wantH int }{ // The smallest crop size that covers 50 × 50 is 96 × 96. {ThumbnailRequest{Width: 50, Height: 50, Method: MethodCrop}, 96, 96}, {ThumbnailRequest{Width: 20, Height: 20, Method: MethodCrop}, 32, 32}, // 320 × 240 scaled from 2:1 keeps the ratio: 320 × 160. {ThumbnailRequest{Width: 300, Height: 200, Method: MethodScale}, 320, 160}, // Larger than every size: the largest, 800 × 600 → 800 × 400. {ThumbnailRequest{Width: 2000, Height: 2000, Method: MethodScale}, 800, 400}, } { c, err := api.Thumbnail(ctx, serverName, id, tc.req, FetchOptions{}) if err != nil { t.Fatalf("Thumbnail(%+v): %v", tc.req, err) } if c.ContentType != "image/png" { t.Errorf("Thumbnail(%+v) is %s, want image/png", tc.req, c.ContentType) } b := decodeThumbnail(t, c).Bounds() if b.Dx() != tc.wantW || b.Dy() != tc.wantH { t.Errorf("Thumbnail(%+v) is %d × %d, want %d × %d", tc.req, b.Dx(), b.Dy(), tc.wantW, tc.wantH) } } // The second ask for a size is answered from the stored thumbnail. first, _ := api.store.Thumbnail(ctx, serverName, id, 96, 96, MethodCrop) c, err := api.Thumbnail(ctx, serverName, id, ThumbnailRequest{Width: 96, Height: 96, Method: MethodCrop}, FetchOptions{}) if err != nil { t.Fatal(err) } _ = c.Body.Close() if c.SHA256 != first.SHA256 { t.Error("the stored thumbnail was not reused") } } func TestThumbnailOfSmallImageIsTheOriginal(t *testing.T) { api := newTestAPI(t, nil) original := encodePNG(t, testImage(100, 50)) id := uploadBytes(t, api, "image/png", original) c, err := api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 640, Height: 480, Method: MethodScale}, FetchOptions{}) if err != nil { t.Fatal(err) } if got := readAll(t, c); !bytes.Equal(got, original) { t.Error("a thumbnail larger than the image was not the image itself") } // A crop of a small image is never enlarged: 100 × 50 cropped to a // square is 50 × 50. c, err = api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 96, Height: 96, Method: MethodCrop}, FetchOptions{}) if err != nil { t.Fatal(err) } if b := decodeThumbnail(t, c).Bounds(); b.Dx() != 50 || b.Dy() != 50 { t.Errorf("crop of a small image is %d × %d, want 50 × 50", b.Dx(), b.Dy()) } } func TestThumbnailOfGIFIsStill(t *testing.T) { api := newTestAPI(t, nil) pal := image.NewPaletted(image.Rect(0, 0, 10, 10), []color.Color{color.Black, color.White}) var buf bytes.Buffer if err := gif.EncodeAll(&buf, &gif.GIF{Image: []*image.Paletted{pal, pal}, Delay: []int{10, 10}}); err != nil { t.Fatal(err) } id := uploadBytes(t, api, "image/gif", buf.Bytes()) c, err := api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 32, Height: 32, Method: MethodScale, Animated: true}, FetchOptions{}) if err != nil { t.Fatal(err) } _ = c.Body.Close() if c.ContentType != "image/png" { t.Errorf("thumbnail of a GIF is %s, want a still image/png", c.ContentType) } } func TestThumbnailRefusals(t *testing.T) { api := newTestAPI(t, func(m *config.Media) { m.MaxThumbnailPixels = 100 * 100 }) ctx := t.Context() req := ThumbnailRequest{Width: 32, Height: 32, Method: MethodCrop} text := uploadBytes(t, api, "text/plain", []byte("not an image")) if _, err := api.Thumbnail(ctx, serverName, text, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) { t.Errorf("thumbnail of text = %v, want ErrCannotThumbnail", err) } liar := uploadBytes(t, api, "image/png", []byte("not a png")) if _, err := api.Thumbnail(ctx, serverName, liar, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) { t.Errorf("thumbnail of a broken PNG = %v, want ErrCannotThumbnail", err) } huge := uploadBytes(t, api, "image/png", encodePNG(t, testImage(200, 200))) if _, err := api.Thumbnail(ctx, serverName, huge, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) { t.Errorf("thumbnail past the pixel limit = %v, want ErrCannotThumbnail", err) } none := newTestAPI(t, func(m *config.Media) { m.ThumbnailSizes = nil }) id := uploadBytes(t, none, "image/png", encodePNG(t, testImage(10, 10))) if _, err := none.Thumbnail(ctx, serverName, id, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) { t.Errorf("thumbnail with no sizes configured = %v, want ErrCannotThumbnail", err) } } func TestRemoteThumbnail(t *testing.T) { api := newTestAPI(t, nil) ctx := t.Context() thumb := encodePNG(t, testImage(96, 96)) api.remote.thumbs["remote.example/pic"] = thumb req := ThumbnailRequest{Width: 90, Height: 90, Method: MethodCrop} for range 2 { c, err := api.Thumbnail(ctx, "remote.example", "pic", req, FetchOptions{}) if err != nil { t.Fatalf("Thumbnail: %v", err) } if got := readAll(t, c); !bytes.Equal(got, thumb) { t.Error("remote thumbnail content differs") } } if n := api.remote.thumbAsks.Load(); n != 1 { t.Errorf("remote thumbnail fetched %d times, want once", n) } if n := api.remote.downloads.Load(); n != 0 { t.Errorf("the original was downloaded %d times to make a thumbnail", n) } } func TestPickSize(t *testing.T) { api := newTestAPI(t, nil) for _, tc := range []struct { req ThumbnailRequest want thumbnailSize }{ {ThumbnailRequest{Width: 1, Height: 1, Method: MethodCrop}, thumbnailSize{Width: 32, Height: 32, Method: MethodCrop}}, {ThumbnailRequest{Width: 33, Height: 10, Method: MethodCrop}, thumbnailSize{Width: 96, Height: 96, Method: MethodCrop}}, {ThumbnailRequest{Width: 500, Height: 500, Method: MethodCrop}, thumbnailSize{Width: 96, Height: 96, Method: MethodCrop}}, {ThumbnailRequest{Width: 640, Height: 100, Method: MethodScale}, thumbnailSize{Width: 640, Height: 480, Method: MethodScale}}, {ThumbnailRequest{Width: 641, Height: 100, Method: MethodScale}, thumbnailSize{Width: 800, Height: 600, Method: MethodScale}}, } { got, ok := api.pickSize(tc.req) if !ok || got != tc.want { t.Errorf("pickSize(%+v) = %+v, want %+v", tc.req, got, tc.want) } } scaleOnly := newTestAPI(t, func(m *config.Media) { m.ThumbnailSizes = []config.ThumbnailSize{{Width: 100, Height: 100, Method: MethodScale}} }) if got, ok := scaleOnly.pickSize(ThumbnailRequest{Width: 32, Height: 32, Method: MethodCrop}); !ok || got.Method != MethodScale { t.Errorf("crop with only scale sizes = %+v, %v; want the scale size", got, ok) } } func TestFit(t *testing.T) { for _, tc := range []struct { srcW, srcH, boxW, boxH int method string outW, outH int crop image.Rectangle }{ {1000, 500, 96, 96, MethodCrop, 96, 96, image.Rect(250, 0, 750, 500)}, {500, 1000, 96, 96, MethodCrop, 96, 96, image.Rect(0, 250, 500, 750)}, {1000, 500, 320, 240, MethodScale, 320, 160, image.Rect(0, 0, 1000, 500)}, {50, 20, 320, 240, MethodScale, 50, 20, image.Rect(0, 0, 50, 20)}, {40, 80, 96, 96, MethodCrop, 40, 40, image.Rect(0, 20, 40, 60)}, } { outW, outH, crop := fit(tc.srcW, tc.srcH, tc.boxW, tc.boxH, tc.method) if outW != tc.outW || outH != tc.outH || crop != tc.crop { t.Errorf("fit(%d×%d into %d×%d %s) = %d×%d from %v, want %d×%d from %v", tc.srcW, tc.srcH, tc.boxW, tc.boxH, tc.method, outW, outH, crop, tc.outW, tc.outH, tc.crop) } } } // jpegWithOrientation makes a JPEG with an EXIF orientation tag, the way a // phone camera writes a portrait photo: landscape pixels, and a tag saying // which way is up. func jpegWithOrientation(t *testing.T, img image.Image, orientation int, bigEndian bool) []byte { t.Helper() var plain bytes.Buffer if err := jpeg.Encode(&plain, img, nil); err != nil { t.Fatal(err) } var tiff []byte if bigEndian { tiff = []byte{'M', 'M', 0, 42, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, byte(orientation), 0, 0, 0, 0, 0, 0} } else { tiff = []byte{'I', 'I', 42, 0, 8, 0, 0, 0, 1, 0, 0x12, 0x01, 3, 0, 1, 0, 0, 0, byte(orientation), 0, 0, 0, 0, 0, 0, 0} } payload := append([]byte("Exif\x00\x00"), tiff...) length := len(payload) + 2 app1 := append([]byte{0xFF, 0xE1, byte(length >> 8), byte(length)}, payload...) data := plain.Bytes() out := append([]byte{}, data[:2]...) out = append(out, app1...) return append(out, data[2:]...) } func TestJPEGOrientation(t *testing.T) { img := testImage(40, 20) for _, bigEndian := range []bool{false, true} { for o := 1; o <= 8; o++ { data := jpegWithOrientation(t, img, o, bigEndian) if got := jpegOrientation(bytes.NewReader(data)); got != o { t.Errorf("orientation %d (big endian %v) read as %d", o, bigEndian, got) } } } if got := jpegOrientation(strings.NewReader("not a jpeg")); got != 1 { t.Errorf("orientation of garbage = %d, want 1", got) } } func TestThumbnailIsTurnedUpright(t *testing.T) { api := newTestAPI(t, nil) // Stored 1000 × 500, tagged "a quarter turn clockwise": upright it is // 500 × 1000, portrait. id := uploadBytes(t, api, "image/jpeg", jpegWithOrientation(t, testImage(1000, 500), 6, false)) c, err := api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 320, Height: 240, Method: MethodScale}, FetchOptions{}) if err != nil { t.Fatal(err) } if c.ContentType != "image/jpeg" { t.Errorf("thumbnail of a JPEG is %s", c.ContentType) } if b := decodeThumbnail(t, c).Bounds(); b.Dx() != 120 || b.Dy() != 240 { t.Errorf("upright thumbnail is %d × %d, want 120 × 240", b.Dx(), b.Dy()) } } func TestOrient(t *testing.T) { // A 2 × 1 image, red then blue; orientation 6 turns it a quarter // clockwise, so red ends up on top. img := image.NewRGBA(image.Rect(0, 0, 2, 1)) red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255} img.SetRGBA(0, 0, red) img.SetRGBA(1, 0, blue) out := orient(img, 6).(*image.RGBA) if out.Bounds().Dx() != 1 || out.Bounds().Dy() != 2 { t.Fatalf("turned image is %v", out.Bounds()) } if out.RGBAAt(0, 0) != red || out.RGBAAt(0, 1) != blue { t.Errorf("turned pixels are %v, %v", out.RGBAAt(0, 0), out.RGBAAt(0, 1)) } out = orient(img, 8).(*image.RGBA) if out.RGBAAt(0, 0) != blue || out.RGBAAt(0, 1) != red { t.Errorf("anticlockwise pixels are %v, %v", out.RGBAAt(0, 0), out.RGBAAt(0, 1)) } } func TestContentDisposition(t *testing.T) { for _, tc := range []struct { contentType, name, want string }{ {"image/png", "", "inline"}, {"image/png; foo=bar", "cat.png", "inline; filename=cat.png"}, {"text/html", "page.html", "attachment; filename=page.html"}, {"image/svg+xml", "", "attachment"}, {"application/octet-stream", "a b.bin", `attachment; filename="a b.bin"`}, {"text/plain", "zażółć.txt", "inline; filename*=utf-8''za%C5%BC%C3%B3%C5%82%C4%87.txt"}, } { if got := ContentDisposition(tc.contentType, tc.name); got != tc.want { t.Errorf("ContentDisposition(%q, %q) = %q, want %q", tc.contentType, tc.name, got, tc.want) } } } func TestFileNameFrom(t *testing.T) { for in, want := range map[string]string{ "": "", `attachment; filename="a.txt"`: "a.txt", `inline; filename="../../etc/passwd"`: "passwd", `inline; filename*=utf-8''%C5%BC.png`: "ż.png", "this is not a disposition header at all": "", } { if got := fileNameFrom(in); got != want { t.Errorf("fileNameFrom(%q) = %q, want %q", in, got, want) } } }