🏡 index : quine-global/quintodrome.git

package jellyfin

import (
	"bytes"
	"context"
	"encoding/base64"
	"errors"
	"image"
	"image/gif"
	"image/jpeg"
	"image/png"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/conf/configtest"
	"github.com/navidrome/navidrome/core/artwork"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/server/jellyfin/dto"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

type fakeArtwork struct {
	artwork.Artwork
	recvId   string
	recvSize int
	recvCtx  context.Context
	data     []byte
	hash     string
}

func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) {
	f.recvId = id
	f.recvSize = size
	f.recvCtx = ctx
	data := f.data
	if data == nil {
		data = []byte("IMG")
	}
	return &artwork.Image{
		ReadCloser:  io.NopCloser(bytes.NewReader(data)),
		Hash:        f.hash,
		LastUpdated: time.Now(),
	}, nil
}

func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) {
	w := httptest.NewRecorder()
	r := httptest.NewRequest("GET", "/Items/"+itemId+"/Images/Primary", nil)
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add("itemId", itemId)
	rctx.URLParams.Add("type", "Primary")
	r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
	return w, r
}

var _ = Describe("Images", func() {
	// Real Jellyfin fits the image inside either bound, so a client that sends only MaxHeight must
	// still get a resized image rather than the full-size original.
	DescribeTable("derives the requested size from the Jellyfin size params",
		func(query string, wantSize int) {
			ds := &tests.MockDataStore{}
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			fa := &fakeArtwork{}
			api := &Router{ds: ds, artwork: fa}

			w, r := newImageRequest(dto.EncodeID(testID("a1")))
			r.URL.RawQuery = query
			api.getItemImage(w, r)

			Expect(w.Code).To(Equal(http.StatusOK))
			Expect(fa.recvSize).To(Equal(wantSize))
		},
		Entry("MaxWidth only", "maxwidth=300", 300),
		Entry("MaxHeight only", "maxheight=300", 300),
		Entry("both, smaller bound wins", "maxwidth=200&maxheight=300", 200),
		Entry("both, smaller bound wins regardless of order", "maxwidth=300&maxheight=200", 200),
		Entry("neither", "", 0),
		Entry("Width only", "width=300", 300),
		Entry("Height only", "height=300", 300),
		Entry("Width capped by MaxWidth", "width=500&maxwidth=300", 300),
		Entry("FillWidth only", "fillwidth=300", 300),
		Entry("FillHeight only", "fillheight=300", 300),
		Entry("both fill sides, larger one covers the box", "fillwidth=200&fillheight=300", 300),
		Entry("fill smaller than MaxWidth wins", "maxwidth=500&fillwidth=300&fillheight=300", 300),
		Entry("MaxWidth smaller than fill wins", "maxwidth=200&fillwidth=300&fillheight=300", 200),
		Entry("non-positive values are ignored", "fillwidth=0&maxwidth=-5&height=250", 250),
	)

	It("streams album artwork", func() {
		ds := &tests.MockDataStore{}
		ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
		fa := &fakeArtwork{}
		api := &Router{ds: ds, artwork: fa}

		w, r := newImageRequest(dto.EncodeID(testID("a1")))
		api.getItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusOK))
		Expect(w.Body.String()).To(Equal("IMG"))
		Expect(fa.recvId).To(ContainSubstring(testID("a1")))
	})

	// A malformed itemId now 404s via itemIDParam instead of falling through to a placeholder image.
	It("404s a malformed itemId instead of serving a placeholder", func() {
		ds := &tests.MockDataStore{}
		fa := &fakeArtwork{}
		api := &Router{ds: ds, artwork: fa}

		w, r := newImageRequest("not-a-valid-id")
		api.getItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusNotFound))
		Expect(fa.recvId).To(BeEmpty(), "artwork resolution must not run for an undecodable id")
	})

	// resolveArtworkID probes the entity tables, so a deleted item yields no artwork id at all.
	It("asks for no artwork once the item is deleted, rather than its lingering state", func() {
		ds := &tests.MockDataStore{} // no albums/artists/tracks/playlists at all
		fa := &fakeArtwork{}
		api := &Router{ds: ds, artwork: fa}

		w, r := newImageRequest(dto.EncodeID(testID("deleted-item")))
		api.getItemImage(w, r)

		// 200 here is what separates a well-formed unknown id from a malformed one, which 404s.
		Expect(w.Code).To(Equal(http.StatusOK))
		Expect(fa.recvId).To(BeEmpty(), "an empty artwork id can only yield a placeholder")
	})

	It("sniffs the Content-Type instead of hardcoding it", func() {
		ds := &tests.MockDataStore{}
		ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})

		png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 512)...)
		fa := &fakeArtwork{data: png}
		api := &Router{ds: ds, artwork: fa}

		w, r := newImageRequest(dto.EncodeID(testID("a1")))
		api.getItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusOK))
		Expect(w.Header().Get("Content-Type")).To(Equal("image/png"))
	})

	It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() {
		ds := &tests.MockDataStore{}
		ds.Playlist().(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("pl1"), Name: "Mix", OwnerID: testID("someone")}})
		fa := &fakeArtwork{}
		api := &Router{ds: ds, artwork: fa}

		w, r := newImageRequest(dto.EncodeID(testID("pl1")))
		api.getItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusOK))
		Expect(fa.recvId).To(ContainSubstring(testID("pl1")))
	})

	// This endpoint is public (no user in the request), so artwork must be resolved under an
	// elevated context; otherwise a private playlist's cover fails its visibility filter and
	// silently falls back to the placeholder.
	It("resolves artwork under an elevated admin context", func() {
		ds := &tests.MockDataStore{}
		ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
		fa := &fakeArtwork{}
		api := &Router{ds: ds, artwork: fa}

		w, r := newImageRequest(dto.EncodeID(testID("a1")))
		api.getItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusOK))
		u, ok := request.UserFrom(fa.recvCtx)
		Expect(ok).To(BeTrue())
		Expect(u.IsAdmin).To(BeTrue())
	})

	It("serves immutable when the tag param asserts the current hash", func() {
		const hash = "0123456789abcdef"
		ds := &tests.MockDataStore{}
		ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
		fa := &fakeArtwork{hash: hash}
		api := &Router{ds: ds, artwork: fa}

		w, r := newImageRequest(dto.EncodeID(testID("a1")))
		q := r.URL.Query()
		q.Set("tag", hash)
		r.URL.RawQuery = q.Encode()
		api.getItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusOK))
		Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable"))
		Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`))
	})

	It("revalidates via no-cache when no tag is provided", func() {
		const hash = "0123456789abcdef"
		ds := &tests.MockDataStore{}
		ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
		fa := &fakeArtwork{hash: hash}
		api := &Router{ds: ds, artwork: fa}

		w, r := newImageRequest(dto.EncodeID(testID("a1")))
		api.getItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusOK))
		Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache"))
	})
})

// Real image fixtures: postItemImage validates uploads by decoding them.
func pngBytes() []byte {
	var b bytes.Buffer
	Expect(png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)))).To(Succeed())
	return b.Bytes()
}

func jpegBytes() []byte {
	var b bytes.Buffer
	Expect(jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed())
	return b.Bytes()
}

func gifBytes() []byte {
	var b bytes.Buffer
	Expect(gif.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed())
	return b.Bytes()
}

// 1x1 WebP (Go's webp support is decode-only, so this one is pre-encoded).
func webpBytes() []byte {
	b, err := base64.StdEncoding.DecodeString(
		"UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAgA0JaACdLoB+AADsAD+8Oj3/yC5YXXI1/8gP+QH/ID/+PIAAAA=")
	Expect(err).ToNot(HaveOccurred())
	return b
}

var _ = Describe("postItemImage", func() {
	var api *Router
	var fp *fakePlaylists

	BeforeEach(func() {
		fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}}
		api = &Router{playlists: fp}
	})

	It("uploads a raw JPEG body and returns 204", func() {
		body := jpegBytes()
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body))
		r.Header.Set("Content-Type", "image/jpeg")
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusNoContent))
		Expect(fp.setImagePlaylistID).To(Equal(testID("pl1")))
		Expect(fp.setImageBytes).To(Equal(body))
		Expect(fp.setImageExt).To(Equal(".jpeg"))
	})

	It("base64-decodes the body and derives the extension from the actual format, not Content-Type", func() {
		raw := pngBytes()
		encoded := base64.StdEncoding.EncodeToString(raw)
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader([]byte(encoded)))
		r.Header.Set("Content-Type", "image/jpeg") // lies: the payload is a PNG
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusNoContent))
		Expect(fp.setImageBytes).To(Equal(raw))
		Expect(fp.setImageExt).To(Equal(".png"))
	})

	It("returns 501 for a non-playlist item, draining the body first", func() {
		fp.getByIDPls = nil
		fp.getByIDErr = model.ErrNotFound
		bodyReader := bytes.NewReader([]byte("some-bytes-that-must-be-drained"))
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", bodyReader)
		r.Header.Set("Content-Type", "image/jpeg")
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1")))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusNotImplemented))
		Expect(bodyReader.Len()).To(Equal(0))
	})

	It("returns 500 when the service fails", func() {
		fp.setImageErr = errors.New("boom")
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes()))
		r.Header.Set("Content-Type", "image/jpeg")
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusInternalServerError))
	})

	It("accepts a raw WebP body", func() {
		body := webpBytes()
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body))
		r.Header.Set("Content-Type", "image/webp")
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusNoContent))
		Expect(fp.setImageBytes).To(Equal(body))
		Expect(fp.setImageExt).To(Equal(".webp"))
	})

	It("accepts a raw GIF body", func() {
		body := gifBytes()
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body))
		r.Header.Set("Content-Type", "image/gif")
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusNoContent))
		Expect(fp.setImageBytes).To(Equal(body))
		Expect(fp.setImageExt).To(Equal(".gif"))
	})

	It("rejects an oversized body with 400, like the native endpoint", func() {
		DeferCleanup(configtest.SetupConfig())
		conf.Server.MaxImageUploadSize = "16" // 16 bytes
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes()))
		r.Header.Set("Content-Type", "image/jpeg")
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusBadRequest))
		Expect(fp.setImagePlaylistID).To(BeEmpty(), "must not persist an over-limit upload")
	})

	It("applies the size limit to the decoded image, not the base64 body", func() {
		DeferCleanup(configtest.SetupConfig())
		img := pngBytes()
		// The raw image is exactly at the limit; its base64 form is 4/3 bigger.
		conf.Server.MaxImageUploadSize = strconv.Itoa(len(img))
		body := []byte(base64.StdEncoding.EncodeToString(img))
		Expect(len(body)).To(BeNumerically(">", len(img)))
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body))
		r.Header.Set("Content-Type", "image/png")
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusNoContent))
		Expect(fp.setImageBytes).To(Equal(img))
	})

	It("rejects a base64 body whose decoded image exceeds the limit with 400", func() {
		DeferCleanup(configtest.SetupConfig())
		img := pngBytes()
		conf.Server.MaxImageUploadSize = strconv.Itoa(len(img) - 1)
		body := []byte(base64.StdEncoding.EncodeToString(img))
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body))
		r.Header.Set("Content-Type", "image/png")
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusBadRequest))
		Expect(fp.setImagePlaylistID).To(BeEmpty())
	})

	It("rejects a body that is neither an image nor base64 with 400", func() {
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", strings.NewReader("!!not base64!!"))
		r.Header.Set("Content-Type", "image/jpeg")
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusBadRequest))
		Expect(fp.setImagePlaylistID).To(BeEmpty())
	})

	It("rejects bytes that sniff as an image but don't decode (e.g. a truncated or renamed file)", func() {
		body := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} // JPEG magic, not a JPEG
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body))
		r.Header.Set("Content-Type", "image/jpeg")
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusBadRequest))
		Expect(fp.setImagePlaylistID).To(BeEmpty())
	})

	It("forbids a non-admin upload when artwork upload is disabled", func() {
		DeferCleanup(configtest.SetupConfig())
		conf.Server.EnableArtworkUpload = false
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes()))
		r.Header.Set("Content-Type", "image/jpeg")
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
		r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("u1"), IsAdmin: false}))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusForbidden))
		Expect(fp.setImagePlaylistID).To(BeEmpty())
	})

	It("still allows an admin upload when artwork upload is disabled", func() {
		DeferCleanup(configtest.SetupConfig())
		conf.Server.EnableArtworkUpload = false
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes()))
		r.Header.Set("Content-Type", "image/jpeg")
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))
		r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("admin"), IsAdmin: true}))

		api.postItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusNoContent))
	})
})

var _ = Describe("deleteItemImage", func() {
	It("removes the playlist image and returns 204", func() {
		fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}}
		api := &Router{playlists: fp}
		w := httptest.NewRecorder()
		r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil)
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))

		api.deleteItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusNoContent))
		Expect(fp.removeImagePlaylistID).To(Equal(testID("pl1")))
	})

	It("returns 501 for a non-playlist item", func() {
		fp := &fakePlaylists{getByIDErr: model.ErrNotFound}
		api := &Router{playlists: fp}
		w := httptest.NewRecorder()
		r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", nil)
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1")))

		api.deleteItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusNotImplemented))
	})

	It("returns 500 when the service fails", func() {
		fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}, removeImageErr: errors.New("boom")}
		api := &Router{playlists: fp}
		w := httptest.NewRecorder()
		r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil)
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1")))

		api.deleteItemImage(w, r)

		Expect(w.Code).To(Equal(http.StatusInternalServerError))
	})
})