diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go new file mode 100644 index 000000000..11ada60d6 --- /dev/null +++ b/plugins/host_subsonicapi.go @@ -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 +} diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go new file mode 100644 index 000000000..a9d391481 --- /dev/null +++ b/plugins/host_subsonicapi_test.go @@ -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) +} diff --git a/plugins/manager.go b/plugins/manager.go index 10d0f98fd..a15027c29 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -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) -) diff --git a/plugins/manifest.json b/plugins/manifest.json index 5f903f855..94bae3509 100644 --- a/plugins/manifest.json +++ b/plugins/manifest.json @@ -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 + } + } } } } diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 398a85148..35abb4718 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -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 } diff --git a/plugins/testdata/fake-subsonicapi-plugin/go.mod b/plugins/testdata/fake-subsonicapi-plugin/go.mod new file mode 100644 index 000000000..db8fa74ed --- /dev/null +++ b/plugins/testdata/fake-subsonicapi-plugin/go.mod @@ -0,0 +1,5 @@ +module fake-subsonicapi-plugin + +go 1.24 + +require github.com/extism/go-pdk v1.1.3 diff --git a/plugins/testdata/fake-subsonicapi-plugin/go.sum b/plugins/testdata/fake-subsonicapi-plugin/go.sum new file mode 100644 index 000000000..c15d38292 --- /dev/null +++ b/plugins/testdata/fake-subsonicapi-plugin/go.sum @@ -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= diff --git a/plugins/testdata/fake-subsonicapi-plugin/main.go b/plugins/testdata/fake-subsonicapi-plugin/main.go new file mode 100644 index 000000000..9fbc5f5fe --- /dev/null +++ b/plugins/testdata/fake-subsonicapi-plugin/main.go @@ -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() {} diff --git a/plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go b/plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go new file mode 100644 index 000000000..f3f8cbcef --- /dev/null +++ b/plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go @@ -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 +}