navidrome/plugins/host_subsonicapi.go
Deluan Quintão 45c408a674
feat(plugins): allow Plugins to call the Subsonic API (#4260)
* chore: .gitignore any navidrome binary

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: implement internal authentication handling in middleware

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(manager): add SubsonicRouter to Manager for API routing

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(plugins): add SubsonicAPI Host service for plugins and an example plugin

Signed-off-by: Deluan <deluan@navidrome.org>

* fix lint

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(plugins): refactor path handling in SubsonicAPI to extract endpoint correctly

Signed-off-by: Deluan <deluan@navidrome.org>

* docs(plugins): add SubsonicAPI service documentation to README

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(plugins): implement permission checks for SubsonicAPI service

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(plugins): enhance SubsonicAPI service initialization with atomic router handling

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(plugins): better encapsulated dependency injection

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(plugins): rename parameter in WithInternalAuth for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* docs(plugins): update SubsonicAPI permissions section in README for clarity and detail

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(plugins): enhance SubsonicAPI permissions output with allowed usernames and admin flag

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(plugins): add schema reference to example plugins

Signed-off-by: Deluan <deluan@navidrome.org>

* remove import alias

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-06-25 14:18:32 -04:00

167 lines
4.9 KiB
Go

package plugins
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"path"
"strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins/host/subsonicapi"
"github.com/navidrome/navidrome/plugins/schema"
"github.com/navidrome/navidrome/server/subsonic"
)
// SubsonicAPIService is the interface for the Subsonic API service
//
// Authentication: The plugin must provide valid authentication parameters in the URL:
// - Required: `u` (username) - The service validates this parameter is present
// - Example: `"/rest/ping?u=admin"`
//
// URL Format: Only the path and query parameters from the URL are used - host, protocol, and method are ignored
//
// Automatic Parameters: The service automatically adds:
// - `c`: Plugin name (client identifier)
// - `v`: Subsonic API version (1.16.1)
// - `f`: Response format (json)
//
// See example usage in the `plugins/examples/subsonicapi-demo` plugin
type subsonicAPIServiceImpl struct {
pluginID string
router SubsonicRouter
ds model.DataStore
permissions *subsonicAPIPermissions
}
func newSubsonicAPIService(pluginID string, router *SubsonicRouter, ds model.DataStore, permissions *schema.PluginManifestPermissionsSubsonicapi) subsonicapi.SubsonicAPIService {
return &subsonicAPIServiceImpl{
pluginID: pluginID,
router: *router,
ds: ds,
permissions: parseSubsonicAPIPermissions(permissions),
}
}
func (s *subsonicAPIServiceImpl) Call(ctx context.Context, req *subsonicapi.CallRequest) (*subsonicapi.CallResponse, error) {
if s.router == nil {
return &subsonicapi.CallResponse{
Error: "SubsonicAPI router not available",
}, nil
}
// Parse the input URL
parsedURL, err := url.Parse(req.Url)
if err != nil {
return &subsonicapi.CallResponse{
Error: fmt.Sprintf("invalid URL format: %v", err),
}, nil
}
// Extract query parameters
query := parsedURL.Query()
// Validate that 'u' (username) parameter is present
username := query.Get("u")
if username == "" {
return &subsonicapi.CallResponse{
Error: "missing required parameter 'u' (username)",
}, nil
}
if err := s.checkPermissions(ctx, username); err != nil {
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err)
return &subsonicapi.CallResponse{Error: err.Error()}, nil
}
// Add required Subsonic API parameters
query.Set("c", s.pluginID) // Client name (plugin ID)
query.Set("f", "json") // Response format
query.Set("v", subsonic.Version) // API version
// 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 internal authentication
httpReq, err := http.NewRequestWithContext(ctx, "GET", finalURL.String(), nil)
if err != nil {
return &subsonicapi.CallResponse{
Error: fmt.Sprintf("failed to create HTTP request: %v", err),
}, nil
}
// 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 the response body as JSON
return &subsonicapi.CallResponse{
Json: recorder.Body.String(),
}, nil
}
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
if s.permissions == nil {
return nil
}
if len(s.permissions.AllowedUsernames) > 0 {
if _, ok := s.permissions.usernameMap[strings.ToLower(username)]; !ok {
return fmt.Errorf("username %s is not allowed", username)
}
}
if !s.permissions.AllowAdmins {
if s.router == nil {
return fmt.Errorf("permissions check failed: router not available")
}
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
}
if usr.IsAdmin {
return fmt.Errorf("calling SubsonicAPI as admin user is not allowed")
}
}
return nil
}
type subsonicAPIPermissions struct {
AllowedUsernames []string
AllowAdmins bool
usernameMap map[string]struct{}
}
func parseSubsonicAPIPermissions(data *schema.PluginManifestPermissionsSubsonicapi) *subsonicAPIPermissions {
if data == nil {
return &subsonicAPIPermissions{}
}
perms := &subsonicAPIPermissions{
AllowedUsernames: data.AllowedUsernames,
AllowAdmins: data.AllowAdmins,
usernameMap: make(map[string]struct{}),
}
for _, u := range data.AllowedUsernames {
perms.usernameMap[strings.ToLower(u)] = struct{}{}
}
return perms
}