fix(jellycompat): harden signed image tags

This commit is contained in:
Quick
2026-05-26 21:50:10 -04:00
parent 0788e7c211
commit deb8305a1d
7 changed files with 187 additions and 33 deletions
+35 -22
View File
@@ -204,6 +204,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
e.rating_tmdb,
e.air_date,
e.still_path,
COALESCE(e.still_thumbhash, ''),
e.updated_at,
e.season_number,
e.episode_number,
si.content_id,
@@ -212,6 +214,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
si.content_rating,
si.poster_path,
si.backdrop_path,
COALESCE(si.backdrop_thumbhash, ''),
si.logo_path,
si.status
FROM %s
@@ -236,6 +239,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
ratingTMDB *float64
airDate *time.Time
stillPath string
stillThumbhash string
updatedAt time.Time
seasonNumber int
episodeNumber int
seriesID string
@@ -244,6 +249,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
contentRating string
seriesPosterPath string
seriesBackdrop string
seriesBackdropTH string
seriesLogoPath string
status string
)
@@ -256,6 +262,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
&ratingTMDB,
&airDate,
&stillPath,
&stillThumbhash,
&updatedAt,
&seasonNumber,
&episodeNumber,
&seriesID,
@@ -264,6 +272,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
&contentRating,
&seriesPosterPath,
&seriesBackdrop,
&seriesBackdropTH,
&seriesLogoPath,
&status,
); err != nil {
@@ -271,28 +280,31 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
}
listItem := upstreamListItem{
ContentID: contentID,
Type: "episode",
Title: title,
Genres: genres,
ContentRating: contentRating,
Status: status,
RatingIMDB: ratingIMDB,
RatingTMDB: ratingTMDB,
Overview: overview,
PosterURL: h.presignCompatImagePath(ctx, stillPath, "still"),
BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
LogoURL: h.presignCompatImagePath(ctx, seriesLogoPath, "logo"),
StillURL: h.presignCompatImagePath(ctx, stillPath, "still"),
PosterPath: stillPath,
BackdropPath: seriesBackdrop,
LogoPath: seriesLogoPath,
StillPath: stillPath,
SeriesID: seriesID,
SeriesTitle: seriesTitle,
SeasonNumber: intPtr(seasonNumber),
EpisodeNumber: intPtr(episodeNumber),
Runtime: runtime,
ContentID: contentID,
Type: "episode",
Title: title,
Genres: genres,
ContentRating: contentRating,
Status: status,
RatingIMDB: ratingIMDB,
RatingTMDB: ratingTMDB,
Overview: overview,
PosterURL: h.presignCompatImagePath(ctx, stillPath, "still"),
BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
LogoURL: h.presignCompatImagePath(ctx, seriesLogoPath, "logo"),
StillURL: h.presignCompatImagePath(ctx, stillPath, "still"),
PosterPath: stillPath,
BackdropPath: seriesBackdrop,
BackdropThumbhash: seriesBackdropTH,
LogoPath: seriesLogoPath,
StillPath: stillPath,
StillThumbhash: stillThumbhash,
UpdatedAt: updatedAt,
SeriesID: seriesID,
SeriesTitle: seriesTitle,
SeasonNumber: intPtr(seasonNumber),
EpisodeNumber: intPtr(episodeNumber),
Runtime: runtime,
}
if airDate != nil {
listItem.AirDate = airDate.Format(time.DateOnly)
@@ -385,6 +397,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context
BackdropThumbhash: series.BackdropThumbhash,
LogoPath: series.LogoPath,
StillPath: episode.StillPath,
StillThumbhash: episode.StillThumbhash,
UpdatedAt: episode.UpdatedAt,
SeriesID: episode.SeriesID,
SeriesTitle: series.Title,
+13 -10
View File
@@ -69,16 +69,20 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
routeID := chiURLParam(r, "id")
imageType := chiURLParam(r, "imageType")
imageSize := compatRequestImageSize(r, imageType)
if imageURL, ok := h.images.LookupSized(routeID, imageType, r.URL.Query().Get("tag"), imageSize); ok {
h.proxyImageURL(w, r, imageURL)
return
}
if imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, r); ok || err != nil {
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
if tag != "" {
imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, imageSize, tag)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
h.proxyImageURL(w, r, imageURL.URL)
if ok {
h.images.RememberSizedUntil(routeID, imageType, imageURL.URL, imageSize, imageURL.ExpiresAt)
h.proxyImageURL(w, r, imageURL.URL)
return
}
} else if imageURL, ok := h.images.LookupSized(routeID, imageType, "", imageSize); ok {
h.proxyImageURL(w, r, imageURL)
return
}
@@ -228,16 +232,15 @@ func (h *ImagesHandler) resolveItemImageURLFromRepos(ctx context.Context, sessio
return catalog.ResolvedImageURL{}, false, nil
}
func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID, imageType string, r *http.Request) (catalog.ResolvedImageURL, bool, error) {
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
if tag == "" {
func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) {
if h.imageTags == nil || tag == "" {
return catalog.ResolvedImageURL{}, false, nil
}
contentID, err := decodeContentID(h.codec, routeID)
if err != nil {
return catalog.ResolvedImageURL{}, false, nil
}
return h.resolveItemImageURLFromReposWithoutSession(ctx, contentID, imageType, compatRequestImageSize(r, imageType), tag)
return h.resolveItemImageURLFromReposWithoutSession(ctx, contentID, imageType, imageSize, tag)
}
func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.Context, contentID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) {
+6
View File
@@ -15,6 +15,9 @@ type imageTagSigner struct {
}
func newImageTagSigner(secret string) *imageTagSigner {
if strings.TrimSpace(secret) == "" {
return nil
}
return &imageTagSigner{secret: []byte(secret)}
}
@@ -34,6 +37,9 @@ func (s *imageTagSigner) Tag(seed, fallbackURL string) string {
}
func (s *imageTagSigner) Equal(seed, fallbackURL, actual string) bool {
if s == nil {
return false
}
actual = strings.TrimSpace(actual)
expected := s.Tag(seed, fallbackURL)
if expected == "" || actual == "" || len(expected) != len(actual) {
+95
View File
@@ -125,6 +125,7 @@ func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: NewImageCache(time.Hour, func() time.Time { return updatedAt }),
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(cfg.Auth.JWTSecret),
}
@@ -141,6 +142,100 @@ func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
if got := rec.Body.String(); got != "image-bytes" {
t.Fatalf("body = %q, want image bytes", got)
}
if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" {
t.Fatal("signed-tag image URL was not cached after resolution")
}
}
func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) {
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
item := &models.MediaItem{
ContentID: contentID,
PosterPath: "https://cdn.example.test/poster.jpg",
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
tag := newMapper(codec, &config.Config{}).itemFromList(upstreamListItem{
ContentID: contentID,
Type: "movie",
Title: "Movie",
PosterURL: item.PosterPath,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
httpClient: http.DefaultClient,
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(""),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s; want 401", rec.Code, rec.Body.String())
}
}
func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) {
called := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = w.Write([]byte("stale-image"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
item := &models.MediaItem{
ContentID: contentID,
PosterPath: upstream.URL,
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
cache := NewImageCache(time.Hour, func() time.Time { return updatedAt })
cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize)
tag := newMapper(codec, &config.Config{
Auth: config.AuthConfig{JWTSecret: "old-secret"},
}).itemFromList(upstreamListItem{
ContentID: contentID,
Type: "movie",
Title: "Movie",
PosterURL: item.PosterPath,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: cache,
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner("new-secret"),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s; want 401", rec.Code, rec.Body.String())
}
if called {
t.Fatal("served cached image before validating the signed tag")
}
}
type fakeImageItemRepo struct {
+9 -1
View File
@@ -105,8 +105,9 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *
dto.ChildCount = *item.SeasonCount
dto.RecursiveItemCount = *item.SeasonCount
}
primaryPath, primaryThumbhash := listItemPrimaryImageSeedParts(item)
if tags := imageTagsWithSeed(m.imageTagSigner,
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, firstNonEmpty(item.PosterPath, item.StillPath), item.PosterThumbhash, item.UpdatedAt),
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, primaryPath, primaryThumbhash, item.UpdatedAt),
item.PosterURL,
); tags != nil {
dto.ImageTags = tags
@@ -659,6 +660,13 @@ func backdropTagsWithSeed(signer *imageTagSigner, seed, imageURL string) []strin
return []string{signer.Tag(seed, imageURL)}
}
func listItemPrimaryImageSeedParts(item upstreamListItem) (string, string) {
if item.Type == "episode" && item.StillPath != "" {
return item.StillPath, item.StillThumbhash
}
return firstNonEmpty(item.PosterPath, item.StillPath), item.PosterThumbhash
}
func imageTagSeed(routeID, imageType, size, rawPath, thumbhash string, updatedAt time.Time) string {
rawPath = strings.TrimSpace(rawPath)
thumbhash = strings.TrimSpace(thumbhash)
@@ -78,3 +78,31 @@ func TestItemImageTagsUseConfiguredSecret(t *testing.T) {
t.Fatalf("signed image tag did not change with configured secret: %q", first.ImageTags["Primary"])
}
}
func TestEpisodeListImageTagsUseStillThumbhash(t *testing.T) {
secret := "image-secret"
updatedAt := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
item := upstreamListItem{
ContentID: "episode-1",
Type: "episode",
Title: "Episode",
PosterURL: "https://cdn.example.test/still.jpg?sig=one",
PosterPath: "metadb://still/episode-1",
PosterThumbhash: "poster-thumbhash",
StillPath: "metadb://still/episode-1",
StillThumbhash: "still-thumbhash",
UpdatedAt: updatedAt,
}
dto := newMapper(NewResourceIDCodec(), &config.Config{
Auth: config.AuthConfig{JWTSecret: secret},
}).itemFromList(item, false, nil, nil)
expected := newImageTagSigner(secret).Tag(
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, item.StillPath, item.StillThumbhash, updatedAt),
item.PosterURL,
)
if dto.ImageTags["Primary"] != expected {
t.Fatalf("primary tag = %q, want still-thumbhash seed %q", dto.ImageTags["Primary"], expected)
}
}
+1
View File
@@ -36,6 +36,7 @@ type upstreamListItem struct {
BackdropThumbhash string `json:"-"`
LogoPath string `json:"-"`
StillPath string `json:"-"`
StillThumbhash string `json:"-"`
UpdatedAt time.Time `json:"-"`
SeasonCount *int `json:"season_count,omitempty"`
SeriesID string `json:"series_id,omitempty"`