navidrome/plugins/host_subsonicapi.go
2026-02-13 15:47:51 -05:00

152 lines
4.6 KiB
Go

package plugins
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"path"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins/host"
)
// subsonicAPIVersion is the Subsonic API version used for plugin calls.
// This is defined locally to avoid import cycle with server/subsonic.
const subsonicAPIVersion = "1.16.1"
// subsonicAPIServiceImpl implements host.SubsonicAPIService.
// It provides plugins with access to Navidrome's Subsonic API.
//
// Authentication: The plugin must provide a valid 'u' (username) parameter in the URL.
// URL Format: Only the path and query parameters are used - host/protocol are ignored.
// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format).
type subsonicAPIServiceImpl struct {
pluginName string
router SubsonicRouter
ds model.DataStore
userAccess UserAccess
}
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
func newSubsonicAPIService(pluginName string, router SubsonicRouter, ds model.DataStore, userAccess UserAccess) host.SubsonicAPIService {
return &subsonicAPIServiceImpl{
pluginName: pluginName,
router: router,
ds: ds,
userAccess: userAccess,
}
}
// executeRequest handles URL parsing, validation, permission checks, HTTP request creation,
// and router invocation. Shared between Call and CallRaw.
// If setJSON is true, the 'f=json' query parameter is added.
func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string, setJSON bool) (*httptest.ResponseRecorder, error) {
if s.router == nil {
return nil, fmt.Errorf("SubsonicAPI router not available")
}
// Parse the input URL
parsedURL, err := url.Parse(uri)
if err != nil {
return nil, fmt.Errorf("invalid URL format: %w", err)
}
// Extract query parameters
query := parsedURL.Query()
// Validate that 'u' (username) parameter is present
username := query.Get("u")
if username == "" {
return nil, fmt.Errorf("missing required parameter 'u' (username)")
}
if err := s.checkPermissions(ctx, username); err != nil {
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginName, "user", username, err)
return nil, err
}
// Add required Subsonic API parameters
query.Set("c", s.pluginName) // Client name (plugin ID)
query.Set("v", subsonicAPIVersion) // API version
if setJSON {
query.Set("f", "json") // Response format
}
// Extract the endpoint from the path
endpoint := path.Base(parsedURL.Path)
// Build the final URL with processed path and modified query parameters
finalURL := &url.URL{
Path: "/" + endpoint,
RawQuery: query.Encode(),
}
// Use http.NewRequest (not WithContext) to avoid inheriting Chi RouteContext;
// auth context is set explicitly below via request.WithInternalAuth.
httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
// Set internal authentication context using the username from the 'u' parameter
authCtx := request.WithInternalAuth(httpReq.Context(), username)
httpReq = httpReq.WithContext(authCtx)
// Use ResponseRecorder to capture the response
recorder := httptest.NewRecorder()
// Call the subsonic router
s.router.ServeHTTP(recorder, httpReq)
return recorder, nil
}
func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) {
recorder, err := s.executeRequest(ctx, uri, true)
if err != nil {
return "", err
}
return recorder.Body.String(), nil
}
func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (string, []byte, error) {
recorder, err := s.executeRequest(ctx, uri, false)
if err != nil {
return "", nil, err
}
contentType := recorder.Header().Get("Content-Type")
return contentType, recorder.Body.Bytes(), nil
}
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
if s.userAccess.allUsers {
return nil
}
// Must have at least one allowed user configured
if !s.userAccess.HasConfiguredUsers() {
return fmt.Errorf("no users configured for plugin %s", s.pluginName)
}
// Look up the user by username to get their ID
usr, err := s.ds.User(ctx).FindByUsername(username)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return fmt.Errorf("username %s not found", username)
}
return err
}
// Check if the user's ID is in the allowed list
if !s.userAccess.IsAllowed(usr.ID) {
return fmt.Errorf("user %s is not authorized for this plugin", username)
}
return nil
}