🏡 index : quine-global/quintodrome.git

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 {
	pluginID string
	router   SubsonicRouter
	ds       model.DataStore
	users    userAccess // users this plugin may act as (from DB configuration)
}

// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, users userAccess) host.SubsonicAPIService {
	return &subsonicAPIServiceImpl{
		pluginID: pluginID,
		router:   router,
		ds:       ds,
		users:    users,
	}
}

// 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.pluginID, "user", username, err)
		return nil, err
	}

	// Add required Subsonic API parameters
	query.Set("c", s.pluginID)         // 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(),
	}

	// Create HTTP request with a fresh context to avoid Chi RouteContext pollution.
	// Using http.NewRequest (instead of http.NewRequestWithContext) ensures the internal
	// SubsonicAPI call doesn't inherit routing information from the parent handler,
	// which would cause Chi to invoke the wrong handler. Authentication context is
	// explicitly added in the next step 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 allUsers is true, allow any user
	if s.users.allUsers {
		return nil
	}

	// Must have at least one allowed user ID configured
	if len(s.users.userIDSet) == 0 {
		return fmt.Errorf("no users configured for plugin %s", s.pluginID)
	}

	// Look up the user by username to get their ID
	usr, err := s.ds.User().FindByUsername(ctx, 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.users.allows(usr.ID) {
		return fmt.Errorf("user %s is not authorized for this plugin", username)
	}

	return nil
}