mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-31 17:26:22 -04:00
Share repository read methods (Get, GetAll, Read, ReadAll, Exists, Count, CountAll) did not apply an owner filter, so non-admin users saw shares belonging to other users. The write paths already enforced per-user ownership; this brings reads in line with them. Add an addRestriction()/ownerFilter() based scope to share reads, keeping admins and the headless public-share resolution path unrestricted. Route share and player Delete through a new base-repo deleteOwned() primitive that applies the ownership predicate in the DELETE's WHERE clause (atomic, no select-then- delete window) and classifies a zero-row result as permission-denied vs not-found, mirroring updateOwned. The addRestriction helper and the write-miss classifier are hoisted onto the base repository so player and share share one implementation. Also map rest.ErrPermissionDenied and rest.ErrNotFound in the Subsonic error handler so ownership/not-found failures from the rest-backed repositories return the proper Subsonic codes (50 / 70) instead of a generic error. Covered by unit tests (persistence, subsonic error mapping) and an end-to-end cross-user sharing isolation test.
214 lines
6.9 KiB
Go
214 lines
6.9 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("sendResponse", func() {
|
|
var (
|
|
w *httptest.ResponseRecorder
|
|
r *http.Request
|
|
payload *responses.Subsonic
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
w = httptest.NewRecorder()
|
|
r = httptest.NewRequest("GET", "/somepath", nil)
|
|
payload = &responses.Subsonic{
|
|
Status: responses.StatusOK,
|
|
Version: "1.16.1",
|
|
}
|
|
})
|
|
|
|
When("format is JSON", func() {
|
|
It("should set Content-Type to application/json and return the correct body", func() {
|
|
q := r.URL.Query()
|
|
q.Add("f", "json")
|
|
r.URL.RawQuery = q.Encode()
|
|
|
|
sendResponse(w, r, payload)
|
|
|
|
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
|
Expect(w.Body.String()).NotTo(BeEmpty())
|
|
|
|
var wrapper responses.JsonWrapper
|
|
err := json.Unmarshal(w.Body.Bytes(), &wrapper)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(wrapper.Subsonic.Status).To(Equal(payload.Status))
|
|
Expect(wrapper.Subsonic.Version).To(Equal(payload.Version))
|
|
})
|
|
})
|
|
|
|
When("format is JSONP", func() {
|
|
It("should set Content-Type to application/javascript and return the correct callback body", func() {
|
|
q := r.URL.Query()
|
|
q.Add("f", "jsonp")
|
|
q.Add("callback", "testCallback")
|
|
r.URL.RawQuery = q.Encode()
|
|
|
|
sendResponse(w, r, payload)
|
|
|
|
Expect(w.Header().Get("Content-Type")).To(Equal("application/javascript"))
|
|
body := w.Body.String()
|
|
Expect(body).To(SatisfyAll(
|
|
HavePrefix("testCallback("),
|
|
HaveSuffix(")"),
|
|
))
|
|
|
|
// Extract JSON from the JSONP response
|
|
jsonBody := body[strings.Index(body, "(")+1 : strings.LastIndex(body, ")")]
|
|
var wrapper responses.JsonWrapper
|
|
err := json.Unmarshal([]byte(jsonBody), &wrapper)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(wrapper.Subsonic.Status).To(Equal(payload.Status))
|
|
})
|
|
|
|
It("should accept valid callback names with dots", func() {
|
|
q := r.URL.Query()
|
|
q.Add("f", "jsonp")
|
|
q.Add("callback", "jQuery.callback_123")
|
|
r.URL.RawQuery = q.Encode()
|
|
|
|
sendResponse(w, r, payload)
|
|
|
|
body := w.Body.String()
|
|
Expect(body).To(HavePrefix("jQuery.callback_123("))
|
|
})
|
|
|
|
It("should reject callback with invalid characters", func() {
|
|
q := r.URL.Query()
|
|
q.Add("f", "jsonp")
|
|
q.Add("callback", "alert(1)//")
|
|
r.URL.RawQuery = q.Encode()
|
|
|
|
sendResponse(w, r, payload)
|
|
|
|
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
|
var wrapper responses.JsonWrapper
|
|
err := json.Unmarshal(w.Body.Bytes(), &wrapper)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
|
|
Expect(wrapper.Subsonic.Error.Message).To(ContainSubstring("invalid callback parameter"))
|
|
})
|
|
|
|
It("should reject empty callback parameter", func() {
|
|
q := r.URL.Query()
|
|
q.Add("f", "jsonp")
|
|
q.Add("callback", "")
|
|
r.URL.RawQuery = q.Encode()
|
|
|
|
sendResponse(w, r, payload)
|
|
|
|
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
|
var wrapper responses.JsonWrapper
|
|
err := json.Unmarshal(w.Body.Bytes(), &wrapper)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
|
|
})
|
|
})
|
|
|
|
When("format is XML or unspecified", func() {
|
|
It("should set Content-Type to application/xml and return the correct body", func() {
|
|
// No format specified, expecting XML by default
|
|
sendResponse(w, r, payload)
|
|
|
|
Expect(w.Header().Get("Content-Type")).To(Equal("application/xml"))
|
|
var subsonicResponse responses.Subsonic
|
|
err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(subsonicResponse.Status).To(Equal(payload.Status))
|
|
Expect(subsonicResponse.Version).To(Equal(payload.Version))
|
|
})
|
|
})
|
|
|
|
When("an error occurs during marshalling", func() {
|
|
It("should return a fail response", func() {
|
|
payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}}
|
|
// An +Inf value will cause an error when marshalling to JSON
|
|
payload.Song.ReplayGain = responses.ReplayGain{TrackGain: new(math.Inf(1))}
|
|
q := r.URL.Query()
|
|
q.Add("f", "json")
|
|
r.URL.RawQuery = q.Encode()
|
|
|
|
sendResponse(w, r, payload)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var wrapper responses.JsonWrapper
|
|
err := json.Unmarshal(w.Body.Bytes(), &wrapper)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
|
|
Expect(wrapper.Subsonic.Version).To(Equal(payload.Version))
|
|
Expect(wrapper.Subsonic.Error.Message).To(ContainSubstring("json: unsupported value: +Inf"))
|
|
})
|
|
})
|
|
|
|
It("responds with HTTP 429 and Retry-After when the transcode limiter rejects", func() {
|
|
w = httptest.NewRecorder()
|
|
r = httptest.NewRequest("GET", "/rest/stream", nil)
|
|
|
|
sendError(w, r, fmt.Errorf("rejected: %w", stream.ErrTooManyTranscodes))
|
|
|
|
Expect(w.Code).To(Equal(http.StatusTooManyRequests))
|
|
Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty())
|
|
|
|
var subsonicResponse responses.Subsonic
|
|
err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(subsonicResponse.Status).To(Equal(responses.StatusFailed))
|
|
Expect(subsonicResponse.Error).ToNot(BeNil())
|
|
Expect(subsonicResponse.Error.Code).To(Equal(responses.ErrorGeneric))
|
|
Expect(subsonicResponse.Error.Message).To(ContainSubstring("transcode"))
|
|
})
|
|
|
|
It("updates status pointer when an error occurs", func() {
|
|
pointer := int32(0)
|
|
|
|
ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) //nolint:govet
|
|
r = r.WithContext(ctx)
|
|
|
|
payload.Status = responses.StatusFailed
|
|
payload.Error = &responses.Error{Code: responses.ErrorDataNotFound}
|
|
|
|
sendResponse(w, r, payload)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
|
|
Expect(pointer).To(Equal(responses.ErrorDataNotFound))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("mapToSubsonicError", func() {
|
|
DescribeTable("maps repository errors to the correct Subsonic error code",
|
|
func(err error, expectedCode int32) {
|
|
subErr := mapToSubsonicError(err)
|
|
Expect(subErr.code).To(Equal(expectedCode))
|
|
},
|
|
Entry("rest.ErrPermissionDenied -> not authorized (50)",
|
|
rest.ErrPermissionDenied, responses.ErrorAuthorizationFail),
|
|
Entry("rest.ErrNotFound -> data not found (70)",
|
|
rest.ErrNotFound, responses.ErrorDataNotFound),
|
|
Entry("model.ErrNotAuthorized -> not authorized (50)",
|
|
model.ErrNotAuthorized, responses.ErrorAuthorizationFail),
|
|
Entry("model.ErrNotFound -> data not found (70)",
|
|
model.ErrNotFound, responses.ErrorDataNotFound),
|
|
Entry("wrapped rest.ErrPermissionDenied is still mapped",
|
|
fmt.Errorf("update share: %w", rest.ErrPermissionDenied), responses.ErrorAuthorizationFail),
|
|
Entry("unknown error -> generic (0)",
|
|
errors.New("boom"), responses.ErrorGeneric),
|
|
)
|
|
})
|