diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index 6d4cf4f43..fa7196ae6 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -9,9 +9,9 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/slice" ) @@ -35,20 +35,10 @@ func Fingerprint() string { return hex.EncodeToString(sum[:]) } -// withAdminUser wraps ctx with the first admin so repos that apply a per-user visibility -// filter (playlists) expose private rows during this headless work; no admin yet -> unchanged. -func withAdminUser(ctx context.Context, ds model.DataStore) context.Context { - admin, err := ds.User(ctx).FindFirstAdmin() - if err != nil || admin == nil || admin.ID == "" { - return ctx - } - return request.WithUser(ctx, *admin) -} - // Backfill enqueues artwork resolution for every entity when the config fingerprint changed // (or was never stored), artists first so those pages resolve before the larger backlog. func Backfill(ctx context.Context, ds model.DataStore) (bool, error) { - ctx = withAdminUser(ctx, ds) + ctx = auth.WithAdminUser(ctx, ds) current := Fingerprint() props := ds.Property(ctx) stored, err := props.DefaultGet(FingerprintPropertyKey, "") diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 0158ca0c6..fefdebf84 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -71,13 +71,9 @@ func processItem(ctx context.Context, deps *workerDeps, item model.ArtworkQueueI } defer res.reader.Close() - data, err := io.ReadAll(io.LimitReader(res.reader, maxImageBytes+1)) + data, err := readCapped(res.reader) if err != nil { - log.Warn(ctx, "artwork: failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed - } - if len(data) > maxImageBytes { - log.Warn(ctx, "artwork: resolved image exceeds size cap", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "cap", maxImageBytes) + log.Warn(ctx, "artwork: failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err) return outcomeFailed } log.Debug(ctx, "artwork: read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data)) @@ -147,19 +143,41 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A return outcomeAbsent } -// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and a -// blurhash computed from a downscaled thumbnail. -func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) { +// readCapped reads r, rejecting anything over maxImageBytes. +func readCapped(r io.Reader) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxImageBytes { + return nil, fmt.Errorf("image exceeds size cap %d", maxImageBytes) + } + return data, nil +} + +// decodeCapped rejects declared dimensions over maxImagePixels BEFORE the +// full-decode allocation, then decodes. +func decodeCapped(data []byte) (image.Image, string, error) { cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { - return nil, fmt.Errorf("decode image config: %w", err) + return nil, "", fmt.Errorf("decode image config: %w", err) } if int64(cfg.Width)*int64(cfg.Height) > maxImagePixels { - return nil, fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels) + return nil, "", fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels) } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { - return nil, fmt.Errorf("decode image: %w", err) + return nil, "", fmt.Errorf("decode image: %w", err) + } + return img, format, nil +} + +// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and a +// blurhash computed from a downscaled thumbnail. +func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) { + img, format, err := decodeCapped(data) + if err != nil { + return nil, err } thumb := makeThumbnail(img, thumbnailSize) @@ -173,8 +191,8 @@ func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwor return &model.Artwork{ Hash: hash, Mime: mimeForFormat(format), - Width: cfg.Width, - Height: cfg.Height, + Width: img.Bounds().Dx(), + Height: img.Bounds().Dy(), BlurHash: bh, }, nil } diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 984533309..8a62b2999 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -392,21 +392,11 @@ func mtimeViaFS(fsys fs.FS, name string) int64 { // decodeTile runs on every sampled album's resolved bytes before processItem's // own maxImageBytes/maxImagePixels guards apply, so it enforces them itself too. func decodeTile(r io.ReadCloser) (image.Image, error) { - data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1)) + data, err := readCapped(r) if err != nil { return nil, err } - if len(data) > maxImageBytes { - return nil, fmt.Errorf("tile image exceeds size cap %d", maxImageBytes) - } - cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) - if err != nil { - return nil, err - } - if int64(cfg.Width)*int64(cfg.Height) > maxImagePixels { - return nil, fmt.Errorf("tile dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels) - } - img, _, err := image.Decode(bytes.NewReader(data)) + img, _, err := decodeCapped(data) if err != nil { return nil, err } diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 3b6ecc809..637af6efb 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "golang.org/x/time/rate" @@ -116,7 +117,7 @@ func (w *Worker) RunPrune(ctx context.Context) error { func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) { // Resolved per drain, not once in Run: the worker starts at boot, possibly before any // admin exists, so a late-created admin is picked up on the next poll (private playlists). - ctx = withAdminUser(ctx, w.deps.ds) + ctx = auth.WithAdminUser(ctx, w.deps.ds) batch, err := w.deps.ds.ArtworkQueue(ctx).DequeueBatch(2 * concurrency) if err != nil { return 0, err