mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
Added validation to ensure the JSONP callback parameter is a valid JavaScript identifier before reflecting it into the response. Invalid callbacks now return a JSON error response instead. This prevents malicious input from being injected into the response body via the callback parameter.
370 lines
12 KiB
Go
370 lines
12 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/core"
|
|
"github.com/navidrome/navidrome/core/artwork"
|
|
"github.com/navidrome/navidrome/core/external"
|
|
"github.com/navidrome/navidrome/core/metrics"
|
|
"github.com/navidrome/navidrome/core/playback"
|
|
"github.com/navidrome/navidrome/core/scrobbler"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/server"
|
|
"github.com/navidrome/navidrome/server/events"
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
|
"github.com/navidrome/navidrome/utils/req"
|
|
)
|
|
|
|
const Version = "1.16.1"
|
|
|
|
var validJSIdentifier = regexp.MustCompile(`^[a-zA-Z_$][a-zA-Z0-9_$.]*$`)
|
|
|
|
type handler = func(*http.Request) (*responses.Subsonic, error)
|
|
type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error)
|
|
|
|
type Router struct {
|
|
http.Handler
|
|
ds model.DataStore
|
|
artwork artwork.Artwork
|
|
streamer core.MediaStreamer
|
|
archiver core.Archiver
|
|
players core.Players
|
|
provider external.Provider
|
|
playlists core.Playlists
|
|
scanner model.Scanner
|
|
broker events.Broker
|
|
scrobbler scrobbler.PlayTracker
|
|
share core.Share
|
|
playback playback.PlaybackServer
|
|
metrics metrics.Metrics
|
|
}
|
|
|
|
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
|
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
|
|
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
|
metrics metrics.Metrics,
|
|
) *Router {
|
|
r := &Router{
|
|
ds: ds,
|
|
artwork: artwork,
|
|
streamer: streamer,
|
|
archiver: archiver,
|
|
players: players,
|
|
provider: provider,
|
|
playlists: playlists,
|
|
scanner: scanner,
|
|
broker: broker,
|
|
scrobbler: scrobbler,
|
|
share: share,
|
|
playback: playback,
|
|
metrics: metrics,
|
|
}
|
|
r.Handler = r.routes()
|
|
return r
|
|
}
|
|
|
|
func (api *Router) routes() http.Handler {
|
|
r := chi.NewRouter()
|
|
|
|
if conf.Server.Prometheus.Enabled {
|
|
r.Use(recordStats(api.metrics))
|
|
}
|
|
|
|
r.Use(postFormToQueryParams)
|
|
|
|
// Public
|
|
h(r, "getOpenSubsonicExtensions", api.GetOpenSubsonicExtensions)
|
|
|
|
// Protected
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(checkRequiredParameters)
|
|
r.Use(authenticate(api.ds))
|
|
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
|
|
|
// Subsonic endpoints, grouped by controller
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
h(r, "ping", api.Ping)
|
|
h(r, "getLicense", api.GetLicense)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
h(r, "getMusicFolders", api.GetMusicFolders)
|
|
h(r, "getIndexes", api.GetIndexes)
|
|
h(r, "getArtists", api.GetArtists)
|
|
h(r, "getGenres", api.GetGenres)
|
|
h(r, "getMusicDirectory", api.GetMusicDirectory)
|
|
h(r, "getArtist", api.GetArtist)
|
|
h(r, "getAlbum", api.GetAlbum)
|
|
h(r, "getSong", api.GetSong)
|
|
h(r, "getAlbumInfo", api.GetAlbumInfo)
|
|
h(r, "getAlbumInfo2", api.GetAlbumInfo)
|
|
h(r, "getArtistInfo", api.GetArtistInfo)
|
|
h(r, "getArtistInfo2", api.GetArtistInfo2)
|
|
h(r, "getTopSongs", api.GetTopSongs)
|
|
h(r, "getSimilarSongs", api.GetSimilarSongs)
|
|
h(r, "getSimilarSongs2", api.GetSimilarSongs2)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
hr(r, "getAlbumList", api.GetAlbumList)
|
|
hr(r, "getAlbumList2", api.GetAlbumList2)
|
|
h(r, "getStarred", api.GetStarred)
|
|
h(r, "getStarred2", api.GetStarred2)
|
|
h(r, "getNowPlaying", api.GetNowPlaying)
|
|
h(r, "getRandomSongs", api.GetRandomSongs)
|
|
h(r, "getSongsByGenre", api.GetSongsByGenre)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
h(r, "setRating", api.SetRating)
|
|
h(r, "star", api.Star)
|
|
h(r, "unstar", api.Unstar)
|
|
h(r, "scrobble", api.Scrobble)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
h(r, "getPlaylists", api.GetPlaylists)
|
|
h(r, "getPlaylist", api.GetPlaylist)
|
|
h(r, "createPlaylist", api.CreatePlaylist)
|
|
h(r, "deletePlaylist", api.DeletePlaylist)
|
|
h(r, "updatePlaylist", api.UpdatePlaylist)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
h(r, "getBookmarks", api.GetBookmarks)
|
|
h(r, "createBookmark", api.CreateBookmark)
|
|
h(r, "deleteBookmark", api.DeleteBookmark)
|
|
h(r, "getPlayQueue", api.GetPlayQueue)
|
|
h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex)
|
|
h(r, "savePlayQueue", api.SavePlayQueue)
|
|
h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
h(r, "search2", api.Search2)
|
|
h(r, "search3", api.Search3)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
h(r, "getUser", api.GetUser)
|
|
h(r, "getUsers", api.GetUsers)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
h(r, "getScanStatus", api.GetScanStatus)
|
|
h(r, "startScan", api.StartScan)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
hr(r, "getAvatar", api.GetAvatar)
|
|
h(r, "getLyrics", api.GetLyrics)
|
|
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
|
|
hr(r, "stream", api.Stream)
|
|
hr(r, "download", api.Download)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
// configure request throttling
|
|
if conf.Server.DevArtworkMaxRequests > 0 {
|
|
log.Debug("Throttling Subsonic getCoverArt endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests,
|
|
"backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout",
|
|
conf.Server.DevArtworkThrottleBacklogTimeout)
|
|
r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
|
|
conf.Server.DevArtworkThrottleBacklogTimeout))
|
|
}
|
|
hr(r, "getCoverArt", api.GetCoverArt)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
h(r, "createInternetRadioStation", api.CreateInternetRadio)
|
|
h(r, "deleteInternetRadioStation", api.DeleteInternetRadio)
|
|
h(r, "getInternetRadioStations", api.GetInternetRadios)
|
|
h(r, "updateInternetRadioStation", api.UpdateInternetRadio)
|
|
})
|
|
if conf.Server.EnableSharing {
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
h(r, "getShares", api.GetShares)
|
|
h(r, "createShare", api.CreateShare)
|
|
h(r, "updateShare", api.UpdateShare)
|
|
h(r, "deleteShare", api.DeleteShare)
|
|
})
|
|
} else {
|
|
h501(r, "getShares", "createShare", "updateShare", "deleteShare")
|
|
}
|
|
|
|
if conf.Server.Jukebox.Enabled {
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(getPlayer(api.players))
|
|
h(r, "jukeboxControl", api.JukeboxControl)
|
|
})
|
|
} else {
|
|
h501(r, "jukeboxControl")
|
|
}
|
|
|
|
// Not Implemented (yet?)
|
|
h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel",
|
|
"deletePodcastEpisode", "downloadPodcastEpisode")
|
|
h501(r, "createUser", "updateUser", "deleteUser", "changePassword")
|
|
|
|
// Deprecated/Won't implement/Out of scope endpoints
|
|
h410(r, "search")
|
|
h410(r, "getChatMessages", "addChatMessage")
|
|
h410(r, "getVideos", "getVideoInfo", "getCaptions", "hls")
|
|
})
|
|
return r
|
|
}
|
|
|
|
// Add a Subsonic handler
|
|
func h(r chi.Router, path string, f handler) {
|
|
hr(r, path, func(_ http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
|
return f(r)
|
|
})
|
|
}
|
|
|
|
// Add a Subsonic handler that requires an http.ResponseWriter (ex: stream, getCoverArt...)
|
|
func hr(r chi.Router, path string, f handlerRaw) {
|
|
handle := func(w http.ResponseWriter, r *http.Request) {
|
|
res, err := f(w, r)
|
|
if err != nil {
|
|
sendError(w, r, err)
|
|
return
|
|
}
|
|
if r.Context().Err() != nil {
|
|
if log.IsGreaterOrEqualTo(log.LevelDebug) {
|
|
log.Warn(r.Context(), "Request was interrupted", "endpoint", r.URL.Path, r.Context().Err())
|
|
}
|
|
return
|
|
}
|
|
if res != nil {
|
|
sendResponse(w, r, res)
|
|
}
|
|
}
|
|
addHandler(r, path, handle)
|
|
}
|
|
|
|
// Add a handler that returns 501 - Not implemented. Used to signal that an endpoint is not implemented yet
|
|
func h501(r chi.Router, paths ...string) {
|
|
for _, path := range paths {
|
|
handle := func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Add("Cache-Control", "no-cache")
|
|
w.WriteHeader(http.StatusNotImplemented)
|
|
_, _ = w.Write([]byte("This endpoint is not implemented, but may be in future releases"))
|
|
}
|
|
addHandler(r, path, handle)
|
|
}
|
|
}
|
|
|
|
// Add a handler that returns 410 - Gone. Used to signal that an endpoint will not be implemented
|
|
func h410(r chi.Router, paths ...string) {
|
|
for _, path := range paths {
|
|
handle := func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusGone)
|
|
_, _ = w.Write([]byte("This endpoint will not be implemented"))
|
|
}
|
|
addHandler(r, path, handle)
|
|
}
|
|
}
|
|
|
|
func addHandler(r chi.Router, path string, handle func(w http.ResponseWriter, r *http.Request)) {
|
|
r.HandleFunc("/"+path, handle)
|
|
r.HandleFunc("/"+path+".view", handle)
|
|
}
|
|
|
|
func mapToSubsonicError(err error) subError {
|
|
switch {
|
|
case errors.Is(err, errSubsonic): // do nothing
|
|
case errors.Is(err, req.ErrMissingParam):
|
|
err = newError(responses.ErrorMissingParameter, err.Error())
|
|
case errors.Is(err, req.ErrInvalidParam):
|
|
err = newError(responses.ErrorGeneric, err.Error())
|
|
case errors.Is(err, model.ErrNotFound):
|
|
err = newError(responses.ErrorDataNotFound, "data not found")
|
|
default:
|
|
err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err))
|
|
}
|
|
var subErr subError
|
|
errors.As(err, &subErr)
|
|
return subErr
|
|
}
|
|
|
|
func sendError(w http.ResponseWriter, r *http.Request, err error) {
|
|
subErr := mapToSubsonicError(err)
|
|
response := newResponse()
|
|
response.Status = responses.StatusFailed
|
|
response.Error = &responses.Error{Code: subErr.code, Message: subErr.Error()}
|
|
|
|
sendResponse(w, r, response)
|
|
}
|
|
|
|
func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) {
|
|
p := req.Params(r)
|
|
f, _ := p.String("f")
|
|
var response []byte
|
|
var err error
|
|
switch f {
|
|
case "json":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
wrapper := &responses.JsonWrapper{Subsonic: *payload}
|
|
response, err = json.Marshal(wrapper)
|
|
case "jsonp":
|
|
callback, _ := p.String("callback")
|
|
if !validJSIdentifier.MatchString(callback) {
|
|
log.Warn(r.Context(), "Invalid JSONP callback parameter", "callback", callback)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
errResp := newResponse()
|
|
errResp.Status = responses.StatusFailed
|
|
errResp.Error = &responses.Error{Code: responses.ErrorGeneric, Message: "invalid callback parameter"}
|
|
response, _ = json.Marshal(responses.JsonWrapper{Subsonic: *errResp})
|
|
break
|
|
}
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
wrapper := &responses.JsonWrapper{Subsonic: *payload}
|
|
response, err = json.Marshal(wrapper)
|
|
response = fmt.Appendf(nil, "%s(%s)", callback, response)
|
|
default:
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
response, err = xml.Marshal(payload)
|
|
}
|
|
// This should never happen, but if it does, we need to know
|
|
if err != nil {
|
|
log.Error(r.Context(), "Error marshalling response", "format", f, err)
|
|
sendError(w, r, err)
|
|
return
|
|
}
|
|
|
|
if payload.Status == responses.StatusOK {
|
|
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
|
log.Debug(r.Context(), "API: Successful response", "endpoint", r.URL.Path, "status", "OK", "body", string(response))
|
|
} else {
|
|
log.Debug(r.Context(), "API: Successful response", "endpoint", r.URL.Path, "status", "OK")
|
|
}
|
|
} else {
|
|
log.Warn(r.Context(), "API: Failed response", "endpoint", r.URL.Path, "error", payload.Error.Code, "message", payload.Error.Message)
|
|
}
|
|
|
|
statusPointer, ok := r.Context().Value(subsonicErrorPointer).(*int32)
|
|
|
|
if ok && statusPointer != nil {
|
|
if payload.Status == responses.StatusOK {
|
|
*statusPointer = 0
|
|
} else {
|
|
*statusPointer = payload.Error.Code
|
|
}
|
|
}
|
|
|
|
if _, err := w.Write(response); err != nil {
|
|
log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
|
|
}
|
|
}
|