update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
170
plugins/host_subsonicapi.go
Normal file
170
plugins/host_subsonicapi.go
Normal file
@@ -0,0 +1,170 @@
|
||||
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 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 &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
|
||||
}
|
||||
Reference in New Issue
Block a user