package publicimport ("context""errors""io""mime""net/http""net/http/httptest""time""github.com/navidrome/navidrome/core""github.com/navidrome/navidrome/model""github.com/navidrome/navidrome/tests". "github.com/onsi/ginkgo/v2". "github.com/onsi/gomega")type mockArchiver struct {called boolerr error}func (m *mockArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error {return nil}func (m *mockArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error {return nil}func (m *mockArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error {return nil}func (m *mockArchiver) ZipShare(_ context.Context, _ *model.Share, w io.Writer) error {m.called = trueif m.err != nil {return m.err}_, _ = w.Write([]byte("zip-contents"))return nil}var _ = Describe("handleDownloads", func() {var ds *tests.MockDataStorevar shareRepo *tests.MockShareRepovar archiver *mockArchivervar pub *RouterBeforeEach(func() {ds = &tests.MockDataStore{}shareRepo = &tests.MockShareRepo{}ds.MockedShare = shareRepoarchiver = &mockArchiver{}pub = &Router{ds: ds, archiver: archiver, share: core.NewShare(ds)}})shareIs := func(s *model.Share) {shareRepo.ID = s.IDshareRepo.Entity = s}makeRequest := func(id string) *httptest.ResponseRecorder {r := httptest.NewRequest("GET", "/public/d/"+id+"?%3Aid="+id, nil)w := httptest.NewRecorder()pub.handleDownloads(w, r)return w}It("sets a Content-Disposition filename from the share description", func() {shareIs(&model.Share{ID: "abc123", Description: "My Mixtape", Downloadable: true})w := makeRequest("abc123")Expect(w.Code).To(Equal(http.StatusOK))Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="My Mixtape.zip"`))Expect(w.Header().Get("Content-Type")).To(Equal("application/zip"))Expect(archiver.called).To(BeTrue())Expect(w.Body.String()).To(Equal("zip-contents"))})It("falls back to the share ID when there is no description", func() {shareIs(&model.Share{ID: "abc123", Downloadable: true})w := makeRequest("abc123")Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="abc123.zip"`))})It("sanitizes characters that are unsafe in a filename", func() {shareIs(&model.Share{ID: "abc123", Description: `AC/DC: Live, 1979`, Downloadable: true})w := makeRequest("abc123")Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="AC_DC_ Live_ 1979.zip"`))})It("sanitizes the UTF-8 filename* as well", func() {shareIs(&model.Share{ID: "abc123", Description: "Sigur Rรณs/Live", Downloadable: true})w := makeRequest("abc123")Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="Sigur Ros_Live.zip"; filename*=utf-8''Sigur%20R%C3%B3s_Live.zip`))})It("does not let the share description inject a second filename parameter", func() {shareIs(&model.Share{ID: "abc123", Description: `mix"; filename="evil.html`, Downloadable: true})w := makeRequest("abc123")disposition := w.Header().Get("Content-Disposition")Expect(disposition).ToNot(ContainSubstring(`filename="evil.html`))_, params, err := mime.ParseMediaType(disposition)Expect(err).ToNot(HaveOccurred())Expect(params["filename"]).To(Equal(`mix_; filename=_evil.html.zip`))})It("returns 403 without invoking the archiver when the share is not downloadable", func() {shareIs(&model.Share{ID: "abc123", Description: "No Download", Downloadable: false})w := makeRequest("abc123")Expect(w.Code).To(Equal(http.StatusForbidden))Expect(archiver.called).To(BeFalse())Expect(w.Header().Get("Content-Disposition")).To(BeEmpty())})It("returns 404 when the share does not exist", func() {shareIs(&model.Share{ID: "other", Downloadable: true})w := makeRequest("missing")Expect(w.Code).To(Equal(http.StatusNotFound))Expect(archiver.called).To(BeFalse())})It("returns 410 when the share has expired", func() {shareIs(&model.Share{ID: "abc123", Downloadable: true, ExpiresAt: new(time.Now().Add(-time.Hour))})w := makeRequest("abc123")Expect(w.Code).To(Equal(http.StatusGone))Expect(archiver.called).To(BeFalse())})It("returns 500 when the share lookup fails", func() {shareRepo.Error = errors.New("db error")w := makeRequest("abc123")Expect(w.Code).To(Equal(http.StatusInternalServerError))Expect(archiver.called).To(BeFalse())})})