From deb8305a1d33be0ac7e7b5e324eaadef13da8a37 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 21:50:10 -0400 Subject: [PATCH] fix(jellycompat): harden signed image tags --- internal/jellycompat/batch_loaders.go | 57 ++++++++----- internal/jellycompat/handlers_images.go | 23 ++--- internal/jellycompat/image_tag_signer.go | 6 ++ internal/jellycompat/images_test.go | 95 +++++++++++++++++++++ internal/jellycompat/mapping.go | 10 ++- internal/jellycompat/mapping_images_test.go | 28 ++++++ internal/jellycompat/upstream_types.go | 1 + 7 files changed, 187 insertions(+), 33 deletions(-) diff --git a/internal/jellycompat/batch_loaders.go b/internal/jellycompat/batch_loaders.go index ad42130b..910c92f5 100644 --- a/internal/jellycompat/batch_loaders.go +++ b/internal/jellycompat/batch_loaders.go @@ -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, diff --git a/internal/jellycompat/handlers_images.go b/internal/jellycompat/handlers_images.go index 82a8b572..8eb19c69 100644 --- a/internal/jellycompat/handlers_images.go +++ b/internal/jellycompat/handlers_images.go @@ -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) { diff --git a/internal/jellycompat/image_tag_signer.go b/internal/jellycompat/image_tag_signer.go index 0f0dab64..a2669043 100644 --- a/internal/jellycompat/image_tag_signer.go +++ b/internal/jellycompat/image_tag_signer.go @@ -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) { diff --git a/internal/jellycompat/images_test.go b/internal/jellycompat/images_test.go index 0b09b260..d164f3e8 100644 --- a/internal/jellycompat/images_test.go +++ b/internal/jellycompat/images_test.go @@ -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 { diff --git a/internal/jellycompat/mapping.go b/internal/jellycompat/mapping.go index 679b7f4a..4cfd0b24 100644 --- a/internal/jellycompat/mapping.go +++ b/internal/jellycompat/mapping.go @@ -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) diff --git a/internal/jellycompat/mapping_images_test.go b/internal/jellycompat/mapping_images_test.go index 1dd68465..7726fc67 100644 --- a/internal/jellycompat/mapping_images_test.go +++ b/internal/jellycompat/mapping_images_test.go @@ -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) + } +} diff --git a/internal/jellycompat/upstream_types.go b/internal/jellycompat/upstream_types.go index 03e6b982..7a3c60e6 100644 --- a/internal/jellycompat/upstream_types.go +++ b/internal/jellycompat/upstream_types.go @@ -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"`