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 }