fix(jellycompat): harden signed image tags
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user