mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(plugins): implement SubsonicAPI host function integration with permissions
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
d93b758b16
commit
26bfbb968d
152
plugins/host_subsonicapi.go
Normal file
152
plugins/host_subsonicapi.go
Normal file
@ -0,0 +1,152 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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), 'f' (format).
|
||||
type subsonicAPIServiceImpl struct {
|
||||
pluginID string
|
||||
router SubsonicRouter
|
||||
ds model.DataStore
|
||||
permissions *subsonicAPIPermissions
|
||||
}
|
||||
|
||||
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
|
||||
func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, permissions *SubsonicAPIPermission) host.SubsonicAPIService {
|
||||
return &subsonicAPIServiceImpl{
|
||||
pluginID: pluginID,
|
||||
router: router,
|
||||
ds: ds,
|
||||
permissions: parseSubsonicAPIPermissions(permissions),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) {
|
||||
if s.router == nil {
|
||||
return "", fmt.Errorf("SubsonicAPI router not available")
|
||||
}
|
||||
|
||||
// Parse the input URL
|
||||
parsedURL, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return "", 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 "", 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 "", err
|
||||
}
|
||||
|
||||
// Add required Subsonic API parameters
|
||||
query.Set("c", s.pluginID) // Client name (plugin ID)
|
||||
query.Set("f", "json") // Response format
|
||||
query.Set("v", subsonicAPIVersion) // 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 "", 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 the response body as JSON
|
||||
return 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 {
|
||||
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 *SubsonicAPIPermission) *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
|
||||
}
|
||||
338
plugins/host_subsonicapi_test.go
Normal file
338
plugins/host_subsonicapi_test.go
Normal file
@ -0,0 +1,338 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("SubsonicAPI Host Function", Ordered, func() {
|
||||
var (
|
||||
manager *Manager
|
||||
tmpDir string
|
||||
router *fakeSubsonicRouter
|
||||
userRepo *tests.MockedUserRepo
|
||||
dataStore *tests.MockDataStore
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "subsonicapi-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy test plugin to temp dir
|
||||
srcPath := filepath.Join(testdataDir, "fake-subsonicapi-plugin.wasm")
|
||||
destPath := filepath.Join(tmpDir, "fake-subsonicapi-plugin.wasm")
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Setup config
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Setup mock router and data store
|
||||
router = &fakeSubsonicRouter{}
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
dataStore = &tests.MockDataStore{MockedUser: userRepo}
|
||||
|
||||
// Add test users
|
||||
_ = userRepo.Put(&model.User{
|
||||
ID: "user1",
|
||||
UserName: "testuser",
|
||||
IsAdmin: false,
|
||||
})
|
||||
_ = userRepo.Put(&model.User{
|
||||
ID: "admin1",
|
||||
UserName: "adminuser",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Create and configure manager
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*pluginInstance),
|
||||
}
|
||||
manager.SetSubsonicRouter(router)
|
||||
manager.SetDataStore(dataStore)
|
||||
|
||||
// Start the manager
|
||||
err = manager.Start(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = manager.Stop()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin Loading", func() {
|
||||
It("loads the plugin with SubsonicAPI permission", func() {
|
||||
manager.mu.RLock()
|
||||
plugin := manager.plugins["fake-subsonicapi-plugin"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
Expect(plugin).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("has the correct manifest", func() {
|
||||
manager.mu.RLock()
|
||||
plugin := manager.plugins["fake-subsonicapi-plugin"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
Expect(plugin).ToNot(BeNil())
|
||||
Expect(plugin.manifest.Name).To(Equal("Fake SubsonicAPI Plugin"))
|
||||
Expect(plugin.manifest.Permissions.Subsonicapi).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SubsonicAPI Call", func() {
|
||||
var plugin *pluginInstance
|
||||
|
||||
BeforeEach(func() {
|
||||
manager.mu.RLock()
|
||||
plugin = manager.plugins["fake-subsonicapi-plugin"]
|
||||
manager.mu.RUnlock()
|
||||
Expect(plugin).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("successfully calls the ping endpoint", func() {
|
||||
instance, err := plugin.create()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer instance.Close(GinkgoT().Context())
|
||||
|
||||
exit, output, err := instance.Call("call_subsonic_api", []byte("/ping?u=testuser"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exit).To(Equal(uint32(0)))
|
||||
|
||||
// Verify the response contains the expected structure
|
||||
var response map[string]any
|
||||
err = json.Unmarshal(output, &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
subsonicResponse, ok := response["subsonic-response"].(map[string]any)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(subsonicResponse["status"]).To(Equal("ok"))
|
||||
})
|
||||
|
||||
It("adds required parameters (c, f, v) to the request", func() {
|
||||
instance, err := plugin.create()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer instance.Close(GinkgoT().Context())
|
||||
|
||||
_, _, err = instance.Call("call_subsonic_api", []byte("/getAlbumList?u=testuser&type=newest"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify the parameters were added
|
||||
Expect(router.lastRequest).ToNot(BeNil())
|
||||
query := router.lastRequest.URL.Query()
|
||||
Expect(query.Get("c")).To(Equal("fake-subsonicapi-plugin"))
|
||||
Expect(query.Get("f")).To(Equal("json"))
|
||||
Expect(query.Get("v")).To(Equal("1.16.1"))
|
||||
Expect(query.Get("type")).To(Equal("newest"))
|
||||
})
|
||||
|
||||
It("returns error when username is missing", func() {
|
||||
instance, err := plugin.create()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer instance.Close(GinkgoT().Context())
|
||||
|
||||
exit, _, err := instance.Call("call_subsonic_api", []byte("/ping"))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(exit).To(Equal(uint32(1)))
|
||||
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("SubsonicAPIService", func() {
|
||||
var (
|
||||
router *fakeSubsonicRouter
|
||||
userRepo *tests.MockedUserRepo
|
||||
dataStore *tests.MockDataStore
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
router = &fakeSubsonicRouter{}
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
dataStore = &tests.MockDataStore{MockedUser: userRepo}
|
||||
|
||||
_ = userRepo.Put(&model.User{
|
||||
ID: "user1",
|
||||
UserName: "testuser",
|
||||
IsAdmin: false,
|
||||
})
|
||||
_ = userRepo.Put(&model.User{
|
||||
ID: "admin1",
|
||||
UserName: "adminuser",
|
||||
IsAdmin: true,
|
||||
})
|
||||
_ = userRepo.Put(&model.User{
|
||||
ID: "user2",
|
||||
UserName: "alloweduser",
|
||||
IsAdmin: false,
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Permission Enforcement", func() {
|
||||
Context("with AllowedUsernames restriction", func() {
|
||||
It("blocks users not in the allowed list", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
|
||||
AllowedUsernames: []string{"alloweduser"},
|
||||
AllowAdmins: true,
|
||||
})
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("not allowed"))
|
||||
})
|
||||
|
||||
It("allows users in the allowed list", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
|
||||
AllowedUsernames: []string{"alloweduser"},
|
||||
AllowAdmins: true,
|
||||
})
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=alloweduser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).To(ContainSubstring("ok"))
|
||||
})
|
||||
|
||||
It("is case-insensitive for usernames", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
|
||||
AllowedUsernames: []string{"AllowedUser"},
|
||||
AllowAdmins: true,
|
||||
})
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=alloweduser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).To(ContainSubstring("ok"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with AllowAdmins=false", func() {
|
||||
It("blocks admin users", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
|
||||
AllowAdmins: false,
|
||||
})
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=adminuser")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("admin user is not allowed"))
|
||||
})
|
||||
|
||||
It("allows non-admin users", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
|
||||
AllowAdmins: false,
|
||||
})
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).To(ContainSubstring("ok"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with AllowAdmins=true", func() {
|
||||
It("allows admin users", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
|
||||
AllowAdmins: true,
|
||||
})
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=adminuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).To(ContainSubstring("ok"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with no permissions set (nil)", func() {
|
||||
It("allows all users", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil)
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).To(ContainSubstring("ok"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("URL Handling", func() {
|
||||
It("returns error for missing username parameter", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil)
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
|
||||
})
|
||||
|
||||
It("returns error for invalid URL", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil)
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "://invalid")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid URL"))
|
||||
})
|
||||
|
||||
It("extracts endpoint from path correctly", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil)
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/rest/ping.view?u=testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The endpoint should be extracted as "ping.view"
|
||||
Expect(router.lastRequest.URL.Path).To(Equal("/ping.view"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Router Availability", func() {
|
||||
It("returns error when router is nil", func() {
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil)
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("router not available"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// fakeSubsonicRouter is a mock Subsonic router that returns predictable responses.
|
||||
type fakeSubsonicRouter struct {
|
||||
lastRequest *http.Request
|
||||
}
|
||||
|
||||
func (r *fakeSubsonicRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
r.lastRequest = req
|
||||
|
||||
// Return a successful ping response
|
||||
response := map[string]any{
|
||||
"subsonic-response": map[string]any{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@ -17,6 +18,8 @@ import (
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins/host"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"github.com/rjeczalik/notify"
|
||||
"github.com/tetratelabs/wazero"
|
||||
@ -31,6 +34,9 @@ const (
|
||||
defaultTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// SubsonicRouter is an http.Handler that serves Subsonic API requests.
|
||||
type SubsonicRouter = http.Handler
|
||||
|
||||
// Manager manages loading and lifecycle of WebAssembly plugins.
|
||||
// It implements both agents.PluginLoader and scrobbler.PluginLoader interfaces.
|
||||
type Manager struct {
|
||||
@ -47,6 +53,10 @@ type Manager struct {
|
||||
watcherDone chan struct{}
|
||||
debounceTimers map[string]*time.Timer
|
||||
debounceMu sync.Mutex
|
||||
|
||||
// SubsonicAPI host function dependencies (set once before Start, not modified after)
|
||||
subsonicRouter SubsonicRouter
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
// pluginInstance represents a loaded plugin
|
||||
@ -79,6 +89,19 @@ func GetManager() *Manager {
|
||||
})
|
||||
}
|
||||
|
||||
// SetSubsonicRouter sets the Subsonic router for SubsonicAPI host functions.
|
||||
// This should be called after the subsonic router is created but before plugins
|
||||
// that require SubsonicAPI access are loaded.
|
||||
func (m *Manager) SetSubsonicRouter(router SubsonicRouter) {
|
||||
m.subsonicRouter = router
|
||||
}
|
||||
|
||||
// SetDataStore sets the data store for plugins that need database access.
|
||||
// This should be called before plugins are loaded.
|
||||
func (m *Manager) SetDataStore(ds model.DataStore) {
|
||||
m.ds = ds
|
||||
}
|
||||
|
||||
// Start initializes the plugin manager and loads plugins from the configured folder.
|
||||
// It should be called once during application startup when plugins are enabled.
|
||||
func (m *Manager) Start(ctx context.Context) error {
|
||||
@ -313,8 +336,14 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
|
||||
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
|
||||
}
|
||||
|
||||
// Create initial compiled plugin (without AllowedHosts)
|
||||
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, nil)
|
||||
// Register stub host functions for initial compilation.
|
||||
// This is necessary because plugins that import host functions will fail to compile
|
||||
// if those functions aren't available at compile time. We use a stub service that
|
||||
// returns an error - the real service will be registered during recompilation.
|
||||
stubHostFunctions := host.RegisterSubsonicAPIHostFunctions(nil)
|
||||
|
||||
// Create initial compiled plugin with stub host functions
|
||||
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, stubHostFunctions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -350,11 +379,31 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
|
||||
capabilities := detectCapabilities(instance)
|
||||
instance.Close(m.ctx)
|
||||
|
||||
// Recompile only if plugin requires HTTP access (AllowedHosts)
|
||||
// Check if recompilation is needed (AllowedHosts or SubsonicAPI permission)
|
||||
needsRecompile := false
|
||||
var hostFunctions []extism.HostFunction
|
||||
|
||||
if hosts := manifest.AllowedHosts(); len(hosts) > 0 {
|
||||
compiled.Close(m.ctx)
|
||||
pluginManifest.AllowedHosts = hosts
|
||||
compiled, err = extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, nil)
|
||||
needsRecompile = true
|
||||
}
|
||||
|
||||
// Register SubsonicAPI host functions if permission is granted
|
||||
if manifest.Permissions != nil && manifest.Permissions.Subsonicapi != nil {
|
||||
perm := manifest.Permissions.Subsonicapi
|
||||
if m.subsonicRouter != nil && m.ds != nil {
|
||||
service := newSubsonicAPIService(name, m.subsonicRouter, m.ds, perm)
|
||||
hostFunctions = append(hostFunctions, host.RegisterSubsonicAPIHostFunctions(service)...)
|
||||
needsRecompile = true
|
||||
} else {
|
||||
log.Warn(m.ctx, "Plugin requires SubsonicAPI but router/datastore not available", "plugin", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Recompile if needed (AllowedHosts or host functions)
|
||||
if needsRecompile {
|
||||
compiled.Close(m.ctx)
|
||||
compiled, err = extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -526,9 +575,3 @@ func toExtismLogLevel(level log.Level) extism.LogLevel {
|
||||
return extism.LogLevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Verify interface implementations at compile time
|
||||
var (
|
||||
_ agents.PluginLoader = (*Manager)(nil)
|
||||
_ scrobbler.PluginLoader = (*Manager)(nil)
|
||||
)
|
||||
|
||||
@ -46,6 +46,9 @@
|
||||
},
|
||||
"config": {
|
||||
"$ref": "#/$defs/ConfigPermission"
|
||||
},
|
||||
"subsonicapi": {
|
||||
"$ref": "#/$defs/SubsonicAPIPermission"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -77,6 +80,29 @@
|
||||
"description": "Explanation for why config access is needed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SubsonicAPIPermission": {
|
||||
"type": "object",
|
||||
"description": "SubsonicAPI service permissions",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Explanation for why SubsonicAPI access is needed"
|
||||
},
|
||||
"allowedUsernames": {
|
||||
"type": "array",
|
||||
"description": "List of usernames the plugin can pass as u. Any user if empty",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"allowAdmins": {
|
||||
"type": "boolean",
|
||||
"description": "If false, reject calls where the u is an admin",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,4 +82,37 @@ type Permissions struct {
|
||||
|
||||
// Http corresponds to the JSON schema field "http".
|
||||
Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"`
|
||||
|
||||
// Subsonicapi corresponds to the JSON schema field "subsonicapi".
|
||||
Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"`
|
||||
}
|
||||
|
||||
// SubsonicAPI service permissions
|
||||
type SubsonicAPIPermission struct {
|
||||
// If false, reject calls where the u is an admin
|
||||
AllowAdmins bool `json:"allowAdmins,omitempty" yaml:"allowAdmins,omitempty" mapstructure:"allowAdmins,omitempty"`
|
||||
|
||||
// List of usernames the plugin can pass as u. Any user if empty
|
||||
AllowedUsernames []string `json:"allowedUsernames,omitempty" yaml:"allowedUsernames,omitempty" mapstructure:"allowedUsernames,omitempty"`
|
||||
|
||||
// Explanation for why SubsonicAPI access is needed
|
||||
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (j *SubsonicAPIPermission) UnmarshalJSON(value []byte) error {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(value, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
type Plain SubsonicAPIPermission
|
||||
var plain Plain
|
||||
if err := json.Unmarshal(value, &plain); err != nil {
|
||||
return err
|
||||
}
|
||||
if v, ok := raw["allowAdmins"]; !ok || v == nil {
|
||||
plain.AllowAdmins = false
|
||||
}
|
||||
*j = SubsonicAPIPermission(plain)
|
||||
return nil
|
||||
}
|
||||
|
||||
5
plugins/testdata/fake-subsonicapi-plugin/go.mod
vendored
Normal file
5
plugins/testdata/fake-subsonicapi-plugin/go.mod
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
module fake-subsonicapi-plugin
|
||||
|
||||
go 1.24
|
||||
|
||||
require github.com/extism/go-pdk v1.1.3
|
||||
2
plugins/testdata/fake-subsonicapi-plugin/go.sum
vendored
Normal file
2
plugins/testdata/fake-subsonicapi-plugin/go.sum
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
74
plugins/testdata/fake-subsonicapi-plugin/main.go
vendored
Normal file
74
plugins/testdata/fake-subsonicapi-plugin/main.go
vendored
Normal file
@ -0,0 +1,74 @@
|
||||
// Test plugin for SubsonicAPI host function integration tests.
|
||||
// Build with: tinygo build -o ../fake-subsonicapi-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Permissions *Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
SubsonicAPI *SubsonicAPIPermission `json:"subsonicapi,omitempty"`
|
||||
}
|
||||
|
||||
type SubsonicAPIPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
|
||||
AllowAdmins bool `json:"allowAdmins,omitempty"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Fake SubsonicAPI Plugin",
|
||||
Author: "Navidrome Test",
|
||||
Version: "1.0.0",
|
||||
Description: "Test plugin for SubsonicAPI host function",
|
||||
Permissions: &Permissions{
|
||||
SubsonicAPI: &SubsonicAPIPermission{
|
||||
Reason: "Testing SubsonicAPI access",
|
||||
AllowedUsernames: nil, // Allow all users
|
||||
AllowAdmins: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
output, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetErrorString("failed to marshal manifest")
|
||||
return 1
|
||||
}
|
||||
pdk.Output(output)
|
||||
return 0
|
||||
}
|
||||
|
||||
// call_subsonic_api is the exported function that tests the SubsonicAPI host function.
|
||||
// Input: URI string (e.g., "/ping?u=testuser")
|
||||
// Output: The raw JSON response from the Subsonic API
|
||||
//
|
||||
//go:wasmexport call_subsonic_api
|
||||
func callSubsonicAPIExport() int32 {
|
||||
// Get the URI from input
|
||||
uri := pdk.InputString()
|
||||
|
||||
// Call the Subsonic API via host function
|
||||
response, err := SubsonicAPICall(uri)
|
||||
if err != nil {
|
||||
pdk.SetErrorString("failed to call SubsonicAPI: " + err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
// Return the response
|
||||
pdk.OutputString(response.ResponseJSON)
|
||||
return 0
|
||||
}
|
||||
|
||||
func main() {}
|
||||
46
plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go
vendored
Normal file
46
plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
// subsonicapiCall is the host function provided by Navidrome to call the Subsonic API.
|
||||
// It takes a URI string and returns a JSON response.
|
||||
//
|
||||
//go:wasmimport extism:host/user subsonicapi_call
|
||||
func subsonicapiCall(uri uint64) uint64
|
||||
|
||||
// SubsonicAPICallResponse matches the host response format.
|
||||
type SubsonicAPICallResponse struct {
|
||||
ResponseJSON string `json:"responseJSON,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SubsonicAPICall is a wrapper around the host subsonicapi_call function.
|
||||
func SubsonicAPICall(uri string) (*SubsonicAPICallResponse, error) {
|
||||
// Allocate memory for the URI string
|
||||
mem := pdk.AllocateString(uri)
|
||||
defer mem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := subsonicapiCall(mem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse the response
|
||||
var response SubsonicAPICallResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.Error != "" {
|
||||
return nil, errors.New(response.Error)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user