mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-08 22:11:03 -05:00
* feat: implement raw binary framing for host function responses Signed-off-by: Deluan <deluan@navidrome.org> * feat: add CallRaw method for Subsonic API to handle binary responses Signed-off-by: Deluan <deluan@navidrome.org> * test: add tests for raw=true methods and binary framing generation Signed-off-by: Deluan <deluan@navidrome.org> * fix: improve error message for malformed raw responses to indicate incomplete header Signed-off-by: Deluan <deluan@navidrome.org> * fix: add wasm_import_module attribute for raw methods and improve content-type handling Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
164 lines
5.2 KiB
Go
164 lines
5.2 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 {
|
|
pluginID string
|
|
router SubsonicRouter
|
|
ds model.DataStore
|
|
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
|
allUsers bool // If true, plugin can access all users
|
|
userIDMap map[string]struct{}
|
|
}
|
|
|
|
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
|
|
func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, allowedUserIDs []string, allUsers bool) host.SubsonicAPIService {
|
|
userIDMap := make(map[string]struct{})
|
|
for _, id := range allowedUserIDs {
|
|
userIDMap[id] = struct{}{}
|
|
}
|
|
return &subsonicAPIServiceImpl{
|
|
pluginID: pluginID,
|
|
router: router,
|
|
ds: ds,
|
|
allowedUserIDs: allowedUserIDs,
|
|
allUsers: allUsers,
|
|
userIDMap: userIDMap,
|
|
}
|
|
}
|
|
|
|
// 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.allUsers {
|
|
return nil
|
|
}
|
|
|
|
// Must have at least one allowed user ID configured
|
|
if len(s.allowedUserIDs) == 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(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 _, ok := s.userIDMap[usr.ID]; !ok {
|
|
return fmt.Errorf("user %s is not authorized for this plugin", username)
|
|
}
|
|
|
|
return nil
|
|
}
|