From 9f7b6870ac1d893e07f364adac35f9fd193474a7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Feb 2026 11:55:10 -0500 Subject: [PATCH 1/8] feat(plugins): implement HTTP endpoint capability for plugins Signed-off-by: Deluan --- cmd/root.go | 10 + consts/consts.go | 12 +- plugins/capabilities/http_endpoint.go | 51 +++ plugins/capability_http_endpoint.go | 14 + plugins/http_endpoint.go | 184 +++++++++ plugins/http_endpoint_test.go | 374 ++++++++++++++++++ plugins/manifest-schema.json | 27 ++ plugins/manifest.go | 17 + plugins/manifest_gen.go | 69 ++++ plugins/pdk/go/httpendpoint/httpendpoint.go | 100 +++++ .../pdk/go/httpendpoint/httpendpoint_stub.go | 64 +++ .../nd-pdk-capabilities/src/httpendpoint.rs | 121 ++++++ .../pdk/rust/nd-pdk-capabilities/src/lib.rs | 1 + .../testdata/test-http-endpoint-public/go.mod | 16 + .../testdata/test-http-endpoint-public/go.sum | 14 + .../test-http-endpoint-public/main.go | 45 +++ .../test-http-endpoint-public/manifest.json | 13 + plugins/testdata/test-http-endpoint/go.mod | 16 + plugins/testdata/test-http-endpoint/go.sum | 14 + plugins/testdata/test-http-endpoint/main.go | 67 ++++ .../testdata/test-http-endpoint/manifest.json | 16 + server/subsonic/middlewares.go | 60 +++ 22 files changed, 1300 insertions(+), 5 deletions(-) create mode 100644 plugins/capabilities/http_endpoint.go create mode 100644 plugins/capability_http_endpoint.go create mode 100644 plugins/http_endpoint.go create mode 100644 plugins/http_endpoint_test.go create mode 100644 plugins/pdk/go/httpendpoint/httpendpoint.go create mode 100644 plugins/pdk/go/httpendpoint/httpendpoint_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs create mode 100644 plugins/testdata/test-http-endpoint-public/go.mod create mode 100644 plugins/testdata/test-http-endpoint-public/go.sum create mode 100644 plugins/testdata/test-http-endpoint-public/main.go create mode 100644 plugins/testdata/test-http-endpoint-public/manifest.json create mode 100644 plugins/testdata/test-http-endpoint/go.mod create mode 100644 plugins/testdata/test-http-endpoint/go.sum create mode 100644 plugins/testdata/test-http-endpoint/main.go create mode 100644 plugins/testdata/test-http-endpoint/manifest.json diff --git a/cmd/root.go b/cmd/root.go index 74a15abc1..5d9f4b01b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,10 +14,13 @@ import ( "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/resources" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/scheduler" + "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/backgrounds" + "github.com/navidrome/navidrome/server/subsonic" "github.com/spf13/cobra" "github.com/spf13/viper" "golang.org/x/sync/errgroup" @@ -138,6 +141,13 @@ func startServer(ctx context.Context) func() error { if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler()) } + if conf.Server.Plugins.Enabled { + manager := GetPluginManager(ctx) + ds := CreateDataStore() + endpointRouter := plugins.NewEndpointRouter(manager, ds, subsonic.ValidateAuth, server.Authenticator) + a.MountRouter("Plugin Endpoints", consts.URLPathPluginEndpoints, endpointRouter) + a.MountRouter("Plugin Subsonic Endpoints", consts.URLPathPluginSubsonicEndpoints, endpointRouter) + } return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey) } } diff --git a/consts/consts.go b/consts/consts.go index eb10fdc03..04051ed8e 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -36,11 +36,13 @@ const ( DevInitialUserName = "admin" DevInitialName = "Dev Admin" - URLPathUI = "/app" - URLPathNativeAPI = "/api" - URLPathSubsonicAPI = "/rest" - URLPathPublic = "/share" - URLPathPublicImages = URLPathPublic + "/img" + URLPathUI = "/app" + URLPathNativeAPI = "/api" + URLPathSubsonicAPI = "/rest" + URLPathPluginEndpoints = "/ext" + URLPathPluginSubsonicEndpoints = "/rest/ext" + URLPathPublic = "/share" + URLPathPublicImages = URLPathPublic + "/img" // DefaultUILoginBackgroundURL uses Navidrome curated background images collection, // available at https://unsplash.com/collections/20072696/navidrome diff --git a/plugins/capabilities/http_endpoint.go b/plugins/capabilities/http_endpoint.go new file mode 100644 index 000000000..446bd50a0 --- /dev/null +++ b/plugins/capabilities/http_endpoint.go @@ -0,0 +1,51 @@ +package capabilities + +// HTTPEndpoint allows plugins to handle incoming HTTP requests. +// Plugins that declare the 'endpoints' permission must implement this capability. +// The host dispatches incoming HTTP requests to the plugin's HandleRequest function. +// +//nd:capability name=httpendpoint required=true +type HTTPEndpoint interface { + // HandleRequest processes an incoming HTTP request and returns a response. + //nd:export name=nd_http_handle_request + HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error) +} + +// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin. +type HTTPHandleRequest struct { + // Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.). + Method string `json:"method"` + // Path is the request path relative to the plugin's base URL. + // For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook". + Path string `json:"path"` + // Query is the raw query string without the leading '?'. + Query string `json:"query,omitempty"` + // Headers contains the HTTP request headers. + Headers map[string][]string `json:"headers,omitempty"` + // Body is the request body content. + Body string `json:"body,omitempty"` + // User contains the authenticated user information. Nil for auth:"none" endpoints. + User *HTTPUser `json:"user,omitempty"` +} + +// HTTPUser contains authenticated user information passed to the plugin. +type HTTPUser struct { + // ID is the internal Navidrome user ID. + ID string `json:"id"` + // Username is the user's login name. + Username string `json:"username"` + // Name is the user's display name. + Name string `json:"name"` + // IsAdmin indicates whether the user has admin privileges. + IsAdmin bool `json:"isAdmin"` +} + +// HTTPHandleResponse is the response returned by the plugin's HandleRequest function. +type HTTPHandleResponse struct { + // Status is the HTTP status code. Defaults to 200 if zero or not set. + Status int `json:"status,omitempty"` + // Headers contains the HTTP response headers to set. + Headers map[string][]string `json:"headers,omitempty"` + // Body is the response body content. + Body string `json:"body,omitempty"` +} diff --git a/plugins/capability_http_endpoint.go b/plugins/capability_http_endpoint.go new file mode 100644 index 000000000..86cd4dadb --- /dev/null +++ b/plugins/capability_http_endpoint.go @@ -0,0 +1,14 @@ +package plugins + +// CapabilityHTTPEndpoint indicates the plugin can handle incoming HTTP requests. +// Detected when the plugin exports the nd_http_handle_request function. +const CapabilityHTTPEndpoint Capability = "HTTPEndpoint" + +const FuncHTTPHandleRequest = "nd_http_handle_request" + +func init() { + registerCapability( + CapabilityHTTPEndpoint, + FuncHTTPHandleRequest, + ) +} diff --git a/plugins/http_endpoint.go b/plugins/http_endpoint.go new file mode 100644 index 000000000..ce125aa88 --- /dev/null +++ b/plugins/http_endpoint.go @@ -0,0 +1,184 @@ +package plugins + +import ( + "io" + "net/http" + "slices" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/capabilities" +) + +const maxEndpointBodySize = 1 << 20 // 1MB + +// SubsonicAuthValidator validates Subsonic authentication and returns the user. +// This is set by the cmd/ package to avoid import cycles (plugins -> server/subsonic). +type SubsonicAuthValidator func(ds model.DataStore, r *http.Request) (*model.User, error) + +// NativeAuthMiddleware is an HTTP middleware that authenticates using JWT tokens. +// This is set by the cmd/ package to avoid import cycles (plugins -> server). +type NativeAuthMiddleware func(ds model.DataStore) func(next http.Handler) http.Handler + +// NewEndpointRouter creates an HTTP handler that dispatches requests to plugin endpoints. +// It should be mounted at both /ext and /rest/ext. The handler uses a catch-all pattern +// because Chi does not support adding routes after startup, and plugins can be loaded/unloaded +// at runtime. Plugin lookup happens per-request under RLock. +func NewEndpointRouter(manager *Manager, ds model.DataStore, subsonicAuth SubsonicAuthValidator, nativeAuth NativeAuthMiddleware) http.Handler { + r := chi.NewRouter() + h := &endpointHandler{ + manager: manager, + ds: ds, + subsonicAuth: subsonicAuth, + nativeAuth: nativeAuth, + } + r.HandleFunc("/{pluginID}/*", h.ServeHTTP) + r.HandleFunc("/{pluginID}", h.ServeHTTP) + return r +} + +type endpointHandler struct { + manager *Manager + ds model.DataStore + subsonicAuth SubsonicAuthValidator + nativeAuth NativeAuthMiddleware +} + +func (h *endpointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + pluginID := chi.URLParam(r, "pluginID") + + h.manager.mu.RLock() + p, ok := h.manager.plugins[pluginID] + h.manager.mu.RUnlock() + + if !ok || !hasCapability(p.capabilities, CapabilityHTTPEndpoint) { + http.NotFound(w, r) + return + } + + if p.manifest.Permissions == nil || p.manifest.Permissions.Endpoints == nil { + http.NotFound(w, r) + return + } + + authType := p.manifest.Permissions.Endpoints.Auth + + switch authType { + case EndpointsPermissionAuthSubsonic: + h.serveWithSubsonicAuth(w, r, p) + case EndpointsPermissionAuthNative: + h.serveWithNativeAuth(w, r, p) + case EndpointsPermissionAuthNone: + h.dispatch(w, r, p) + default: + http.Error(w, "Unknown auth type", http.StatusInternalServerError) + } +} + +func (h *endpointHandler) serveWithSubsonicAuth(w http.ResponseWriter, r *http.Request, p *plugin) { + usr, err := h.subsonicAuth(h.ds, r) + if err != nil { + log.Warn(r.Context(), "Plugin endpoint auth failed", "plugin", p.name, "auth", "subsonic", err) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + ctx := request.WithUser(r.Context(), *usr) + h.dispatch(w, r.WithContext(ctx), p) +} + +func (h *endpointHandler) serveWithNativeAuth(w http.ResponseWriter, r *http.Request, p *plugin) { + h.nativeAuth(h.ds)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.dispatch(w, r, p) + })).ServeHTTP(w, r) +} + +func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *plugin) { + ctx := r.Context() + + // Check user authorization (skip for auth:"none") + if p.manifest.Permissions.Endpoints.Auth != EndpointsPermissionAuthNone { + user, ok := request.UserFrom(ctx) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + if !p.allUsers && !isUserAllowed(p.allowedUserIDs, user.ID) { + log.Warn(ctx, "Plugin endpoint access denied", "plugin", p.name, "user", user.UserName) + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + } + + // Read request body with size limit + body, err := io.ReadAll(io.LimitReader(r.Body, maxEndpointBodySize)) + if err != nil { + log.Error(ctx, "Failed to read request body", "plugin", p.name, err) + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + // Build the plugin request + relPath := "/" + chi.URLParam(r, "*") + if relPath == "/" { + relPath = "" + } + + var httpUser *capabilities.HTTPUser + if p.manifest.Permissions.Endpoints.Auth != EndpointsPermissionAuthNone { + if user, ok := request.UserFrom(ctx); ok { + httpUser = &capabilities.HTTPUser{ + ID: user.ID, + Username: user.UserName, + Name: user.Name, + IsAdmin: user.IsAdmin, + } + } + } + + pluginReq := capabilities.HTTPHandleRequest{ + Method: r.Method, + Path: relPath, + Query: r.URL.RawQuery, + Headers: r.Header, + Body: string(body), + User: httpUser, + } + + // Call the plugin + resp, err := callPluginFunction[capabilities.HTTPHandleRequest, capabilities.HTTPHandleResponse]( + ctx, p, FuncHTTPHandleRequest, pluginReq, + ) + if err != nil { + log.Error(ctx, "Plugin endpoint call failed", "plugin", p.name, "path", relPath, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Write response headers + for key, values := range resp.Headers { + for _, v := range values { + w.Header().Add(key, v) + } + } + + // Write status code (default to 200) + status := resp.Status + if status == 0 { + status = http.StatusOK + } + w.WriteHeader(status) + + // Write response body + if resp.Body != "" { + if _, err := w.Write([]byte(resp.Body)); err != nil { + log.Error(ctx, "Failed to write plugin endpoint response", "plugin", p.name, err) + } + } +} + +// isUserAllowed checks if the given user ID is in the allowed list. +func isUserAllowed(allowedIDs []string, userID string) bool { + return slices.Contains(allowedIDs, userID) +} diff --git a/plugins/http_endpoint_test.go b/plugins/http_endpoint_test.go new file mode 100644 index 000000000..e583ee4a5 --- /dev/null +++ b/plugins/http_endpoint_test.go @@ -0,0 +1,374 @@ +//go:build !windows + +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakeNativeAuth is a mock native auth middleware that authenticates by looking up +// the "X-Test-User" header and setting the user in the context. +func fakeNativeAuth(ds model.DataStore) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username := r.Header.Get("X-Test-User") + if username == "" { + http.Error(w, "Not authenticated", http.StatusUnauthorized) + return + } + user, err := ds.User(r.Context()).FindByUsername(username) + if err != nil { + http.Error(w, "Not authenticated", http.StatusUnauthorized) + return + } + ctx := request.WithUser(r.Context(), *user) + ctx = request.WithUsername(ctx, user.UserName) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// fakeSubsonicAuth is a mock subsonic auth that validates by looking up +// the "u" query parameter. +func fakeSubsonicAuth(ds model.DataStore, r *http.Request) (*model.User, error) { + username := r.URL.Query().Get("u") + if username == "" { + return nil, model.ErrInvalidAuth + } + user, err := ds.User(r.Context()).FindByUsername(username) + if err != nil { + return nil, model.ErrInvalidAuth + } + return user, nil +} + +var _ = Describe("HTTP Endpoint Handler", Ordered, func() { + var ( + manager *Manager + tmpDir string + userRepo *tests.MockedUserRepo + dataStore *tests.MockDataStore + router http.Handler + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "http-endpoint-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Copy both test plugins + for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public"} { + srcPath := filepath.Join(testdataDir, pluginName+PackageExtension) + destPath := filepath.Join(tmpDir, pluginName+PackageExtension) + 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 data store + userRepo = tests.CreateMockUserRepo() + dataStore = &tests.MockDataStore{MockedUser: userRepo} + + // Add test users + _ = userRepo.Put(&model.User{ + ID: "user1", + UserName: "testuser", + Name: "Test User", + IsAdmin: false, + }) + _ = userRepo.Put(&model.User{ + ID: "admin1", + UserName: "adminuser", + Name: "Admin User", + IsAdmin: true, + }) + + // Build enabled plugins list + var enabledPlugins model.Plugins + for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public"} { + pluginPath := filepath.Join(tmpDir, pluginName+PackageExtension) + data, err := os.ReadFile(pluginPath) + Expect(err).ToNot(HaveOccurred()) + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + + enabledPlugins = append(enabledPlugins, model.Plugin{ + ID: pluginName, + Path: pluginPath, + SHA256: hashHex, + Enabled: true, + AllUsers: true, + }) + } + + // Setup mock plugin repo + mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo) + mockPluginRepo.Permitted = true + mockPluginRepo.SetData(enabledPlugins) + + // Create and start manager + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + metrics: noopMetricsRecorder{}, + subsonicRouter: http.NotFoundHandler(), + } + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + // Create the endpoint router with fake auth functions + router = NewEndpointRouter(manager, dataStore, fakeSubsonicAuth, fakeNativeAuth) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + Describe("Plugin Loading", func() { + It("loads the authenticated endpoint plugin", func() { + manager.mu.RLock() + p := manager.plugins["test-http-endpoint"] + manager.mu.RUnlock() + + Expect(p).ToNot(BeNil()) + Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Plugin")) + Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil()) + Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("subsonic")) + Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue()) + }) + + It("loads the public endpoint plugin", func() { + manager.mu.RLock() + p := manager.plugins["test-http-endpoint-public"] + manager.mu.RUnlock() + + Expect(p).ToNot(BeNil()) + Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Public Plugin")) + Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil()) + Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("none")) + Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue()) + }) + }) + + Describe("Subsonic Auth Endpoints", func() { + It("returns hello response with valid auth", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("Hello from plugin!")) + Expect(w.Header().Get("Content-Type")).To(Equal("text/plain")) + }) + + It("returns echo response with request details", func() { + req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser&foo=bar", strings.NewReader("test body")) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var resp map[string]any + err := json.Unmarshal(w.Body.Bytes(), &resp) + Expect(err).ToNot(HaveOccurred()) + Expect(resp["method"]).To(Equal("POST")) + Expect(resp["path"]).To(Equal("/echo")) + Expect(resp["body"]).To(Equal("test body")) + Expect(resp["hasUser"]).To(BeTrue()) + Expect(resp["username"]).To(Equal("testuser")) + }) + + It("returns plugin-defined error status", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint/error?u=testuser", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + Expect(w.Body.String()).To(Equal("Something went wrong")) + }) + + It("returns plugin 404 for unknown paths", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint/unknown?u=testuser", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Body.String()).To(Equal("Not found: /unknown")) + }) + + It("returns 401 without auth credentials", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint/hello", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("returns 401 with invalid auth credentials", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=nonexistent", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("Public Endpoints (auth: none)", func() { + It("returns webhook response without auth", func() { + req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("webhook received")) + }) + + It("does not pass user info to public endpoints", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint-public/check-no-user", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("hasUser=false")) + }) + }) + + Describe("Unknown Plugin", func() { + It("returns 404 for nonexistent plugin", func() { + req := httptest.NewRequest("GET", "/nonexistent-plugin/hello", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("User Authorization", func() { + var restrictedRouter http.Handler + + BeforeAll(func() { + // Create a manager with a plugin restricted to specific users + restrictedTmpDir, err := os.MkdirTemp("", "http-endpoint-restricted-test-*") + Expect(err).ToNot(HaveOccurred()) + + srcPath := filepath.Join(testdataDir, "test-http-endpoint"+PackageExtension) + destPath := filepath.Join(restrictedTmpDir, "test-http-endpoint"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = restrictedTmpDir + conf.Server.Plugins.AutoReload = false + conf.Server.CacheFolder = filepath.Join(restrictedTmpDir, "cache") + + restrictedPluginRepo := tests.CreateMockPluginRepo() + restrictedPluginRepo.Permitted = true + restrictedPluginRepo.SetData(model.Plugins{{ + ID: "test-http-endpoint", + Path: destPath, + SHA256: hashHex, + Enabled: true, + AllUsers: false, + Users: `["admin1"]`, // Only admin1 is allowed + }}) + restrictedDS := &tests.MockDataStore{ + MockedPlugin: restrictedPluginRepo, + MockedUser: userRepo, + } + + restrictedManager := &Manager{ + plugins: make(map[string]*plugin), + ds: restrictedDS, + metrics: noopMetricsRecorder{}, + subsonicRouter: http.NotFoundHandler(), + } + err = restrictedManager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + restrictedRouter = NewEndpointRouter(restrictedManager, restrictedDS, fakeSubsonicAuth, fakeNativeAuth) + + DeferCleanup(func() { + _ = restrictedManager.Stop() + _ = os.RemoveAll(restrictedTmpDir) + }) + }) + + It("allows authorized users", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=adminuser", nil) + w := httptest.NewRecorder() + restrictedRouter.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("Hello from plugin!")) + }) + + It("denies unauthorized users", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil) + w := httptest.NewRecorder() + restrictedRouter.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("Request without trailing path", func() { + It("handles requests to plugin root", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint-public/webhook", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("Request body handling", func() { + It("passes request body to the plugin", func() { + body := `{"event":"push","ref":"refs/heads/main"}` + req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser", strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + respBody, err := io.ReadAll(w.Body) + Expect(err).ToNot(HaveOccurred()) + + var resp map[string]any + err = json.Unmarshal(respBody, &resp) + Expect(err).ToNot(HaveOccurred()) + Expect(resp["body"]).To(Equal(body)) + }) + }) +}) diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 4e64ca6ea..171afcf49 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -110,6 +110,33 @@ }, "users": { "$ref": "#/$defs/UsersPermission" + }, + "endpoints": { + "$ref": "#/$defs/EndpointsPermission" + } + } + }, + "EndpointsPermission": { + "type": "object", + "description": "HTTP endpoint permissions for registering custom HTTP endpoints on the Navidrome server. Requires 'users' permission when auth is 'native' or 'subsonic'.", + "additionalProperties": false, + "required": ["auth"], + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why HTTP endpoint registration is needed" + }, + "auth": { + "type": "string", + "enum": ["native", "subsonic", "none"], + "description": "Authentication type for plugin endpoints: 'native' (JWT), 'subsonic' (params), or 'none' (public/unauthenticated)" + }, + "paths": { + "type": "array", + "description": "Declared endpoint paths (informational, for admin UI display). Relative to plugin base URL.", + "items": { + "type": "string" + } } } }, diff --git a/plugins/manifest.go b/plugins/manifest.go index f401a7f69..d40230d8d 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -32,6 +32,15 @@ func (m *Manifest) Validate() error { } } + // Endpoints permission with auth 'native' or 'subsonic' requires users permission + if m.Permissions != nil && m.Permissions.Endpoints != nil { + if m.Permissions.Endpoints.Auth != EndpointsPermissionAuthNone { + if m.Permissions.Users == nil { + return fmt.Errorf("'endpoints' permission with auth '%s' requires 'users' permission to be declared", m.Permissions.Endpoints.Auth) + } + } + } + // Validate config schema if present if m.Config != nil && m.Config.Schema != nil { if err := validateConfigSchema(m.Config.Schema); err != nil { @@ -64,6 +73,14 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error { return fmt.Errorf("scrobbler capability requires 'users' permission to be declared in manifest") } } + + // HTTPEndpoint capability requires endpoints permission + if hasCapability(capabilities, CapabilityHTTPEndpoint) { + if m.Permissions == nil || m.Permissions.Endpoints == nil { + return fmt.Errorf("HTTP endpoint capability requires 'endpoints' permission to be declared in manifest") + } + } + return nil } diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 27c3c0677..9b6ac49ca 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -4,6 +4,7 @@ package plugins import "encoding/json" import "fmt" +import "reflect" // Artwork service permissions for generating artwork URLs type ArtworkPermission struct { @@ -45,6 +46,71 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error { return nil } +// HTTP endpoint permissions for registering custom HTTP endpoints on the Navidrome +// server. Requires 'users' permission when auth is 'native' or 'subsonic'. +type EndpointsPermission struct { + // Authentication type for plugin endpoints: 'native' (JWT), 'subsonic' (params), + // or 'none' (public/unauthenticated) + Auth EndpointsPermissionAuth `json:"auth" yaml:"auth" mapstructure:"auth"` + + // Declared endpoint paths (informational, for admin UI display). Relative to + // plugin base URL. + Paths []string `json:"paths,omitempty" yaml:"paths,omitempty" mapstructure:"paths,omitempty"` + + // Explanation for why HTTP endpoint registration is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + +type EndpointsPermissionAuth string + +const EndpointsPermissionAuthNative EndpointsPermissionAuth = "native" +const EndpointsPermissionAuthNone EndpointsPermissionAuth = "none" +const EndpointsPermissionAuthSubsonic EndpointsPermissionAuth = "subsonic" + +var enumValues_EndpointsPermissionAuth = []interface{}{ + "native", + "subsonic", + "none", +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *EndpointsPermissionAuth) UnmarshalJSON(value []byte) error { + var v string + if err := json.Unmarshal(value, &v); err != nil { + return err + } + var ok bool + for _, expected := range enumValues_EndpointsPermissionAuth { + if reflect.DeepEqual(v, expected) { + ok = true + break + } + } + if !ok { + return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_EndpointsPermissionAuth, v) + } + *j = EndpointsPermissionAuth(v) + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *EndpointsPermission) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["auth"]; raw != nil && !ok { + return fmt.Errorf("field auth in EndpointsPermission: required") + } + type Plain EndpointsPermission + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + *j = EndpointsPermission(plain) + return nil +} + // Experimental features that may change or be removed in future versions type Experimental struct { // Threads corresponds to the JSON schema field "threads". @@ -166,6 +232,9 @@ type Permissions struct { // Cache corresponds to the JSON schema field "cache". Cache *CachePermission `json:"cache,omitempty" yaml:"cache,omitempty" mapstructure:"cache,omitempty"` + // Endpoints corresponds to the JSON schema field "endpoints". + Endpoints *EndpointsPermission `json:"endpoints,omitempty" yaml:"endpoints,omitempty" mapstructure:"endpoints,omitempty"` + // Http corresponds to the JSON schema field "http". Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"` diff --git a/plugins/pdk/go/httpendpoint/httpendpoint.go b/plugins/pdk/go/httpendpoint/httpendpoint.go new file mode 100644 index 000000000..f927cf081 --- /dev/null +++ b/plugins/pdk/go/httpendpoint/httpendpoint.go @@ -0,0 +1,100 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the HTTPEndpoint capability. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package httpendpoint + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin. +type HTTPHandleRequest struct { + // Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.). + Method string `json:"method"` + // Path is the request path relative to the plugin's base URL. + // For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook". + Path string `json:"path"` + // Query is the raw query string without the leading '?'. + Query string `json:"query,omitempty"` + // Headers contains the HTTP request headers. + Headers map[string][]string `json:"headers,omitempty"` + // Body is the request body content. + Body string `json:"body,omitempty"` + // User contains the authenticated user information. Nil for auth:"none" endpoints. + User *HTTPUser `json:"user,omitempty"` +} + +// HTTPHandleResponse is the response returned by the plugin's HandleRequest function. +type HTTPHandleResponse struct { + // Status is the HTTP status code. Defaults to 200 if zero or not set. + Status int `json:"status,omitempty"` + // Headers contains the HTTP response headers to set. + Headers map[string][]string `json:"headers,omitempty"` + // Body is the response body content. + Body string `json:"body,omitempty"` +} + +// HTTPUser contains authenticated user information passed to the plugin. +type HTTPUser struct { + // ID is the internal Navidrome user ID. + ID string `json:"id"` + // Username is the user's login name. + Username string `json:"username"` + // Name is the user's display name. + Name string `json:"name"` + // IsAdmin indicates whether the user has admin privileges. + IsAdmin bool `json:"isAdmin"` +} + +// HTTPEndpoint requires all methods to be implemented. +// HTTPEndpoint allows plugins to handle incoming HTTP requests. +// Plugins that declare the 'endpoints' permission must implement this capability. +// The host dispatches incoming HTTP requests to the plugin's HandleRequest function. +type HTTPEndpoint interface { + // HandleRequest - HandleRequest processes an incoming HTTP request and returns a response. + HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error) +} // Internal implementation holders +var ( + handleRequestImpl func(HTTPHandleRequest) (HTTPHandleResponse, error) +) + +// Register registers a httpendpoint implementation. +// All methods are required. +func Register(impl HTTPEndpoint) { + handleRequestImpl = impl.HandleRequest +} + +// NotImplementedCode is the standard return code for unimplemented functions. +// The host recognizes this and skips the plugin gracefully. +const NotImplementedCode int32 = -2 + +//go:wasmexport nd_http_handle_request +func _NdHttpHandleRequest() int32 { + if handleRequestImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input HTTPHandleRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := handleRequestImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/httpendpoint/httpendpoint_stub.go b/plugins/pdk/go/httpendpoint/httpendpoint_stub.go new file mode 100644 index 000000000..7a97757f8 --- /dev/null +++ b/plugins/pdk/go/httpendpoint/httpendpoint_stub.go @@ -0,0 +1,64 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file provides stub implementations for non-WASM platforms. +// It allows Go plugins to compile and run tests outside of WASM, +// but the actual functionality is only available in WASM builds. +// +//go:build !wasip1 + +package httpendpoint + +// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin. +type HTTPHandleRequest struct { + // Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.). + Method string `json:"method"` + // Path is the request path relative to the plugin's base URL. + // For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook". + Path string `json:"path"` + // Query is the raw query string without the leading '?'. + Query string `json:"query,omitempty"` + // Headers contains the HTTP request headers. + Headers map[string][]string `json:"headers,omitempty"` + // Body is the request body content. + Body string `json:"body,omitempty"` + // User contains the authenticated user information. Nil for auth:"none" endpoints. + User *HTTPUser `json:"user,omitempty"` +} + +// HTTPHandleResponse is the response returned by the plugin's HandleRequest function. +type HTTPHandleResponse struct { + // Status is the HTTP status code. Defaults to 200 if zero or not set. + Status int `json:"status,omitempty"` + // Headers contains the HTTP response headers to set. + Headers map[string][]string `json:"headers,omitempty"` + // Body is the response body content. + Body string `json:"body,omitempty"` +} + +// HTTPUser contains authenticated user information passed to the plugin. +type HTTPUser struct { + // ID is the internal Navidrome user ID. + ID string `json:"id"` + // Username is the user's login name. + Username string `json:"username"` + // Name is the user's display name. + Name string `json:"name"` + // IsAdmin indicates whether the user has admin privileges. + IsAdmin bool `json:"isAdmin"` +} + +// HTTPEndpoint requires all methods to be implemented. +// HTTPEndpoint allows plugins to handle incoming HTTP requests. +// Plugins that declare the 'endpoints' permission must implement this capability. +// The host dispatches incoming HTTP requests to the plugin's HandleRequest function. +type HTTPEndpoint interface { + // HandleRequest - HandleRequest processes an incoming HTTP request and returns a response. + HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error) +} + +// NotImplementedCode is the standard return code for unimplemented functions. +const NotImplementedCode int32 = -2 + +// Register is a no-op on non-WASM platforms. +// This stub allows code to compile outside of WASM. +func Register(_ HTTPEndpoint) {} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs new file mode 100644 index 000000000..0d639033c --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs @@ -0,0 +1,121 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the HTTPEndpoint capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HTTPHandleRequest { + /// Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.). + #[serde(default)] + pub method: String, + /// Path is the request path relative to the plugin's base URL. + /// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook". + #[serde(default)] + pub path: String, + /// Query is the raw query string without the leading '?'. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub query: String, + /// Headers contains the HTTP request headers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: std::collections::HashMap>, + /// Body is the request body content. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub body: String, + /// User contains the authenticated user information. Nil for auth:"none" endpoints. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, +} +/// HTTPHandleResponse is the response returned by the plugin's HandleRequest function. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HTTPHandleResponse { + /// Status is the HTTP status code. Defaults to 200 if zero or not set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: i32, + /// Headers contains the HTTP response headers to set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: std::collections::HashMap>, + /// Body is the response body content. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub body: String, +} +/// HTTPUser contains authenticated user information passed to the plugin. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HTTPUser { + /// ID is the internal Navidrome user ID. + #[serde(default)] + pub id: String, + /// Username is the user's login name. + #[serde(default)] + pub username: String, + /// Name is the user's display name. + #[serde(default)] + pub name: String, + /// IsAdmin indicates whether the user has admin privileges. + #[serde(default)] + pub is_admin: bool, +} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +/// HTTPEndpoint requires all methods to be implemented. +/// HTTPEndpoint allows plugins to handle incoming HTTP requests. +/// Plugins that declare the 'endpoints' permission must implement this capability. +/// The host dispatches incoming HTTP requests to the plugin's HandleRequest function. +pub trait HTTPEndpoint { + /// HandleRequest - HandleRequest processes an incoming HTTP request and returns a response. + fn handle_request(&self, req: HTTPHandleRequest) -> Result; +} + +/// Register all exports for the HTTPEndpoint capability. +/// This macro generates the WASM export functions for all trait methods. +#[macro_export] +macro_rules! register_httpendpoint { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_http_handle_request( + req: extism_pdk::Json<$crate::httpendpoint::HTTPHandleRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::httpendpoint::HTTPEndpoint::handle_request(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index 0f0daf80f..641308222 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -5,6 +5,7 @@ //! This crate provides type definitions, traits, and registration macros //! for implementing Navidrome plugin capabilities in Rust. +pub mod httpendpoint; pub mod lifecycle; pub mod metadata; pub mod scheduler; diff --git a/plugins/testdata/test-http-endpoint-public/go.mod b/plugins/testdata/test-http-endpoint-public/go.mod new file mode 100644 index 000000000..8d14a497c --- /dev/null +++ b/plugins/testdata/test-http-endpoint-public/go.mod @@ -0,0 +1,16 @@ +module test-http-endpoint-public + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-http-endpoint-public/go.sum b/plugins/testdata/test-http-endpoint-public/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-http-endpoint-public/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-http-endpoint-public/main.go b/plugins/testdata/test-http-endpoint-public/main.go new file mode 100644 index 000000000..55689f6e3 --- /dev/null +++ b/plugins/testdata/test-http-endpoint-public/main.go @@ -0,0 +1,45 @@ +// Test plugin for public (unauthenticated) HTTP endpoint integration tests. +// Build with: tinygo build -o ../test-http-endpoint-public.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/httpendpoint" +) + +func init() { + httpendpoint.Register(&testPublicEndpoint{}) +} + +type testPublicEndpoint struct{} + +func (t *testPublicEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpendpoint.HTTPHandleResponse, error) { + switch req.Path { + case "/webhook": + return httpendpoint.HTTPHandleResponse{ + Status: 200, + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + }, + Body: "webhook received", + }, nil + + case "/check-no-user": + // Verify that no user info is provided for public endpoints + hasUser := "false" + if req.User != nil { + hasUser = "true" + } + return httpendpoint.HTTPHandleResponse{ + Status: 200, + Body: "hasUser=" + hasUser, + }, nil + + default: + return httpendpoint.HTTPHandleResponse{ + Status: 404, + Body: "Not found: " + req.Path, + }, nil + } +} + +func main() {} diff --git a/plugins/testdata/test-http-endpoint-public/manifest.json b/plugins/testdata/test-http-endpoint-public/manifest.json new file mode 100644 index 000000000..40ccad6dd --- /dev/null +++ b/plugins/testdata/test-http-endpoint-public/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Test HTTP Endpoint Public Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test plugin for public (unauthenticated) HTTP endpoint integration testing", + "permissions": { + "endpoints": { + "auth": "none", + "paths": ["/webhook"], + "reason": "Testing public HTTP endpoints" + } + } +} diff --git a/plugins/testdata/test-http-endpoint/go.mod b/plugins/testdata/test-http-endpoint/go.mod new file mode 100644 index 000000000..3d1c407e8 --- /dev/null +++ b/plugins/testdata/test-http-endpoint/go.mod @@ -0,0 +1,16 @@ +module test-http-endpoint + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-http-endpoint/go.sum b/plugins/testdata/test-http-endpoint/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-http-endpoint/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-http-endpoint/main.go b/plugins/testdata/test-http-endpoint/main.go new file mode 100644 index 000000000..0b110c5cf --- /dev/null +++ b/plugins/testdata/test-http-endpoint/main.go @@ -0,0 +1,67 @@ +// Test plugin for HTTP endpoint integration tests. +// Build with: tinygo build -o ../test-http-endpoint.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "encoding/json" + + "github.com/navidrome/navidrome/plugins/pdk/go/httpendpoint" +) + +func init() { + httpendpoint.Register(&testEndpoint{}) +} + +type testEndpoint struct{} + +func (t *testEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpendpoint.HTTPHandleResponse, error) { + switch req.Path { + case "/hello": + return httpendpoint.HTTPHandleResponse{ + Status: 200, + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + }, + Body: "Hello from plugin!", + }, nil + + case "/echo": + // Echo back the request as JSON + data, _ := json.Marshal(map[string]any{ + "method": req.Method, + "path": req.Path, + "query": req.Query, + "body": req.Body, + "hasUser": req.User != nil, + "username": userName(req.User), + }) + return httpendpoint.HTTPHandleResponse{ + Status: 200, + Headers: map[string][]string{ + "Content-Type": {"application/json"}, + }, + Body: string(data), + }, nil + + case "/error": + return httpendpoint.HTTPHandleResponse{ + Status: 500, + Body: "Something went wrong", + }, nil + + default: + return httpendpoint.HTTPHandleResponse{ + Status: 404, + Body: "Not found: " + req.Path, + }, nil + } +} + +func userName(u *httpendpoint.HTTPUser) string { + if u == nil { + return "" + } + return u.Username +} + +func main() {} diff --git a/plugins/testdata/test-http-endpoint/manifest.json b/plugins/testdata/test-http-endpoint/manifest.json new file mode 100644 index 000000000..9de3aba06 --- /dev/null +++ b/plugins/testdata/test-http-endpoint/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "Test HTTP Endpoint Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test plugin for HTTP endpoint integration testing", + "permissions": { + "endpoints": { + "auth": "subsonic", + "paths": ["/hello", "/echo"], + "reason": "Testing HTTP endpoint handling" + }, + "users": { + "reason": "Authenticated endpoints require user access" + } + } +} diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index d984bac42..0c701484d 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -153,6 +153,66 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { } } +// ValidateAuth validates Subsonic authentication from an HTTP request and returns the authenticated user. +// Unlike the authenticate middleware, this function does not write any HTTP response, making it suitable +// for use by external consumers (e.g., plugin endpoints) that need Subsonic auth but want to handle +// errors themselves. +// +// It supports the same authentication methods as the Subsonic API: +// - Internal auth (from plugin SubsonicAPI calls) +// - Reverse proxy auth (via trusted external auth headers) +// - Subsonic classic auth (username + password/token/salt/jwt query params) +func ValidateAuth(ds model.DataStore, r *http.Request) (*model.User, error) { + // Parse form data into query params (same as postFormToQueryParams middleware) + if err := r.ParseForm(); err != nil { + return nil, fmt.Errorf("parsing form: %w", err) + } + var parts []string + for key, values := range r.Form { + for _, v := range values { + parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v)) + } + } + r.URL.RawQuery = strings.Join(parts, "&") + + ctx := r.Context() + + // Check internal auth or reverse proxy auth first + username, isInternalAuth := fromInternalOrProxyAuth(r) + if username != "" { + usr, err := ds.User(ctx).FindByUsername(username) + if err != nil { + authType := If(isInternalAuth, "internal", "reverse-proxy") + log.Warn(ctx, "Plugin auth: Invalid login", "auth", authType, "username", username, err) + return nil, model.ErrInvalidAuth + } + return usr, nil + } + + // Fall back to Subsonic classic auth (query params) + p := req.Params(r) + username, _ = p.String("u") + if username == "" { + return nil, fmt.Errorf("missing required parameter 'u' (username)") + } + + pass, _ := p.String("p") + token, _ := p.String("t") + salt, _ := p.String("s") + jwt, _ := p.String("jwt") + + usr, err := ds.User(ctx).FindByUsernameWithPassword(username) + if err != nil { + return nil, model.ErrInvalidAuth + } + + if err := validateCredentials(usr, pass, token, salt, jwt); err != nil { + return nil, err + } + + return usr, nil +} + func validateCredentials(user *model.User, pass, token, salt, jwt string) error { valid := false From b0f91715b9ccfc403cff8590e71a97b983eb3c6f Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Feb 2026 12:46:36 -0500 Subject: [PATCH 2/8] refactor(plugins): streamline user access management in plugin services Signed-off-by: Deluan --- conf/configuration.go | 14 ++-- plugins/capabilities/http_endpoint.go | 1 + plugins/host_subsonicapi.go | 41 +++++------- plugins/host_subsonicapi_test.go | 36 +++++----- plugins/host_users.go | 22 ++---- plugins/host_users_test.go | 16 ++--- plugins/http_endpoint.go | 19 +++--- plugins/http_endpoint_test.go | 67 ++++++++++++++++++- plugins/manager.go | 15 +---- plugins/manager_loader.go | 33 +++++---- plugins/manager_plugin.go | 17 +++-- plugins/scrobbler_adapter.go | 21 +----- plugins/scrobbler_adapter_test.go | 35 ---------- .../testdata/test-http-endpoint-native/go.mod | 16 +++++ .../testdata/test-http-endpoint-native/go.sum | 14 ++++ .../test-http-endpoint-native/main.go | 61 +++++++++++++++++ .../test-http-endpoint-native/manifest.json | 16 +++++ plugins/user_access.go | 35 ++++++++++ plugins/user_access_test.go | 64 ++++++++++++++++++ 19 files changed, 369 insertions(+), 174 deletions(-) create mode 100644 plugins/testdata/test-http-endpoint-native/go.mod create mode 100644 plugins/testdata/test-http-endpoint-native/go.sum create mode 100644 plugins/testdata/test-http-endpoint-native/main.go create mode 100644 plugins/testdata/test-http-endpoint-native/manifest.json create mode 100644 plugins/user_access.go create mode 100644 plugins/user_access_test.go diff --git a/conf/configuration.go b/conf/configuration.go index e2aca6c13..be424278a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -239,11 +239,13 @@ type inspectOptions struct { } type pluginsOptions struct { - Enabled bool - Folder string - CacheSize string - AutoReload bool - LogLevel string + Enabled bool + Folder string + CacheSize string + AutoReload bool + LogLevel string + EndpointRequestLimit int + EndpointRequestWindow time.Duration } type extAuthOptions struct { @@ -671,6 +673,8 @@ func setViperDefaults() { viper.SetDefault("plugins.enabled", true) viper.SetDefault("plugins.cachesize", "200MB") viper.SetDefault("plugins.autoreload", false) + viper.SetDefault("plugins.endpointrequestlimit", 60) + viper.SetDefault("plugins.endpointrequestwindow", time.Minute) // DevFlags. These are used to enable/disable debugging and incomplete features viper.SetDefault("devlogsourceline", false) diff --git a/plugins/capabilities/http_endpoint.go b/plugins/capabilities/http_endpoint.go index 446bd50a0..e5e7f984c 100644 --- a/plugins/capabilities/http_endpoint.go +++ b/plugins/capabilities/http_endpoint.go @@ -17,6 +17,7 @@ type HTTPHandleRequest struct { Method string `json:"method"` // Path is the request path relative to the plugin's base URL. // For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook". + // Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "". Path string `json:"path"` // Query is the raw query string without the leading '?'. Query string `json:"query,omitempty"` diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go index 01a33c039..ed44d139c 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -26,27 +26,19 @@ const subsonicAPIVersion = "1.16.1" // 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{} + pluginName string + router SubsonicRouter + ds model.DataStore + userAccess UserAccess } // 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{}{} - } +func newSubsonicAPIService(pluginName string, router SubsonicRouter, ds model.DataStore, userAccess UserAccess) host.SubsonicAPIService { return &subsonicAPIServiceImpl{ - pluginID: pluginID, - router: router, - ds: ds, - allowedUserIDs: allowedUserIDs, - allUsers: allUsers, - userIDMap: userIDMap, + pluginName: pluginName, + router: router, + ds: ds, + userAccess: userAccess, } } @@ -74,12 +66,12 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string, } if err := s.checkPermissions(ctx, username); err != nil { - log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err) + log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginName, "user", username, err) return nil, err } // Add required Subsonic API parameters - query.Set("c", s.pluginID) // Client name (plugin ID) + query.Set("c", s.pluginName) // Client name (plugin ID) query.Set("v", subsonicAPIVersion) // API version if setJSON { query.Set("f", "json") // Response format @@ -135,14 +127,13 @@ func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (strin } func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error { - // If allUsers is true, allow any user - if s.allUsers { + if s.userAccess.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) + // Must have at least one allowed user configured + if !s.userAccess.HasConfiguredUsers() { + return fmt.Errorf("no users configured for plugin %s", s.pluginName) } // Look up the user by username to get their ID @@ -155,7 +146,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username } // Check if the user's ID is in the allowed list - if _, ok := s.userIDMap[usr.ID]; !ok { + if !s.userAccess.IsAllowed(usr.ID) { return fmt.Errorf("user %s is not authorized for this plugin", username) } diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index b0589fa12..adc216888 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -268,7 +268,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with specific user IDs allowed", func() { It("blocks users not in the allowed list", func() { // allowedUserIDs contains "user2", but testuser is "user1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"})) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -278,7 +278,7 @@ var _ = Describe("SubsonicAPIService", func() { It("allows users in the allowed list", func() { // allowedUserIDs contains "user2" which is "alloweduser" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"})) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=alloweduser") @@ -288,7 +288,7 @@ var _ = Describe("SubsonicAPIService", func() { It("blocks admin users when not in allowed list", func() { // allowedUserIDs only contains "user1" (testuser), not "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user1"})) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=adminuser") @@ -298,7 +298,7 @@ var _ = Describe("SubsonicAPIService", func() { It("allows admin users when in allowed list", func() { // allowedUserIDs contains "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"admin1"})) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") @@ -309,7 +309,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with allUsers=true", func() { It("allows all users regardless of allowed list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=testuser") @@ -318,7 +318,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("allows admin users when allUsers is true", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") @@ -329,7 +329,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with no users configured", func() { It("returns error when no users are configured", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, nil)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -338,7 +338,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for empty user list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{})) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -350,7 +350,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("URL Handling", func() { It("returns error for missing username parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping") @@ -359,7 +359,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "://invalid") @@ -368,7 +368,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("extracts endpoint from path correctly", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user1"})) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/rest/ping.view?u=testuser") @@ -381,7 +381,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("CallRaw", func() { It("returns binary data and content-type", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil)) ctx := GinkgoT().Context() contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -391,7 +391,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("does not set f=json parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -403,7 +403,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("enforces permission checks", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"})) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -412,7 +412,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error when username is missing", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt") @@ -421,7 +421,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", nil, dataStore, NewUserAccess(true, nil)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser") @@ -430,7 +430,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "://invalid") @@ -441,7 +441,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("Router Availability", func() { It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", nil, dataStore, NewUserAccess(true, nil)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") diff --git a/plugins/host_users.go b/plugins/host_users.go index a56c8f866..c67ba3bb6 100644 --- a/plugins/host_users.go +++ b/plugins/host_users.go @@ -9,16 +9,14 @@ import ( ) type usersServiceImpl struct { - ds model.DataStore - allowedUsers []string // User IDs this plugin can access - allUsers bool // If true, plugin can access all users + ds model.DataStore + userAccess UserAccess } -func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) host.UsersService { +func newUsersService(ds model.DataStore, userAccess UserAccess) host.UsersService { return &usersServiceImpl{ - ds: ds, - allowedUsers: allowedUsers, - allUsers: allUsers, + ds: ds, + userAccess: userAccess, } } @@ -28,17 +26,9 @@ func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) { return nil, err } - // Build allowed users map for efficient lookup - allowedMap := make(map[string]bool, len(s.allowedUsers)) - for _, id := range s.allowedUsers { - allowedMap[id] = true - } - var result []host.User for _, u := range users { - // If allUsers is true, include all users - // Otherwise, only include users in the allowed list - if s.allUsers || allowedMap[u.ID] { + if s.userAccess.IsAllowed(u.ID) { result = append(result, host.User{ UserName: u.UserName, Name: u.Name, diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 2071a9320..99e197190 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -61,7 +61,7 @@ var _ = Describe("UsersService", Ordered, func() { Context("with allUsers=true", func() { BeforeEach(func() { - service = newUsersService(ds, nil, true) + service = newUsersService(ds, NewUserAccess(true, nil)) }) It("should return all users", func() { @@ -100,7 +100,7 @@ var _ = Describe("UsersService", Ordered, func() { Context("with specific allowed users", func() { BeforeEach(func() { // Only allow access to user1 and user3 - service = newUsersService(ds, []string{"user1", "user3"}, false) + service = newUsersService(ds, NewUserAccess(false, []string{"user1", "user3"})) }) It("should return only allowed users", func() { @@ -119,7 +119,7 @@ var _ = Describe("UsersService", Ordered, func() { Context("with empty allowed users and allUsers=false", func() { BeforeEach(func() { - service = newUsersService(ds, []string{}, false) + service = newUsersService(ds, NewUserAccess(false, []string{})) }) It("should return no users", func() { @@ -132,7 +132,7 @@ var _ = Describe("UsersService", Ordered, func() { Context("when datastore returns error", func() { BeforeEach(func() { mockUserRepo.Error = model.ErrNotFound - service = newUsersService(ds, nil, true) + service = newUsersService(ds, NewUserAccess(true, nil)) }) It("should propagate the error", func() { @@ -170,7 +170,7 @@ var _ = Describe("UsersService", Ordered, func() { Context("with allUsers=true", func() { BeforeEach(func() { - service = newUsersService(ds, nil, true) + service = newUsersService(ds, NewUserAccess(true, nil)) }) It("should return only admin users", func() { @@ -185,7 +185,7 @@ var _ = Describe("UsersService", Ordered, func() { Context("with specific allowed users including admin", func() { BeforeEach(func() { // Allow access to user1 (admin) and user2 (non-admin) - service = newUsersService(ds, []string{"user1", "user2"}, false) + service = newUsersService(ds, NewUserAccess(false, []string{"user1", "user2"})) }) It("should return only admin users from allowed list", func() { @@ -199,7 +199,7 @@ var _ = Describe("UsersService", Ordered, func() { Context("with specific allowed users excluding admin", func() { BeforeEach(func() { // Only allow access to non-admin users - service = newUsersService(ds, []string{"user2", "user3"}, false) + service = newUsersService(ds, NewUserAccess(false, []string{"user2", "user3"})) }) It("should return empty when no admins in allowed list", func() { @@ -212,7 +212,7 @@ var _ = Describe("UsersService", Ordered, func() { Context("when datastore returns error", func() { BeforeEach(func() { mockUserRepo.Error = model.ErrNotFound - service = newUsersService(ds, nil, true) + service = newUsersService(ds, NewUserAccess(true, nil)) }) It("should propagate the error", func() { diff --git a/plugins/http_endpoint.go b/plugins/http_endpoint.go index ce125aa88..025ce249d 100644 --- a/plugins/http_endpoint.go +++ b/plugins/http_endpoint.go @@ -3,9 +3,10 @@ package plugins import ( "io" "net/http" - "slices" "github.com/go-chi/chi/v5" + "github.com/go-chi/httprate" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -28,6 +29,12 @@ type NativeAuthMiddleware func(ds model.DataStore) func(next http.Handler) http. // at runtime. Plugin lookup happens per-request under RLock. func NewEndpointRouter(manager *Manager, ds model.DataStore, subsonicAuth SubsonicAuthValidator, nativeAuth NativeAuthMiddleware) http.Handler { r := chi.NewRouter() + + // Apply rate limiting if configured + if conf.Server.Plugins.EndpointRequestLimit > 0 { + r.Use(httprate.LimitByIP(conf.Server.Plugins.EndpointRequestLimit, conf.Server.Plugins.EndpointRequestWindow)) + } + h := &endpointHandler{ manager: manager, ds: ds, @@ -104,7 +111,7 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - if !p.allUsers && !isUserAllowed(p.allowedUserIDs, user.ID) { + if !p.userAccess.IsAllowed(user.ID) { log.Warn(ctx, "Plugin endpoint access denied", "plugin", p.name, "user", user.UserName) http.Error(w, "Forbidden", http.StatusForbidden) return @@ -120,8 +127,9 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl } // Build the plugin request + // Normalize path: both /ext/plugin and /ext/plugin/ map to "" relPath := "/" + chi.URLParam(r, "*") - if relPath == "/" { + if relPath == "/" || relPath == "" { relPath = "" } @@ -177,8 +185,3 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl } } } - -// isUserAllowed checks if the given user ID is in the allowed list. -func isUserAllowed(allowedIDs []string, userID string) bool { - return slices.Contains(allowedIDs, userID) -} diff --git a/plugins/http_endpoint_test.go b/plugins/http_endpoint_test.go index e583ee4a5..1f6d3de97 100644 --- a/plugins/http_endpoint_test.go +++ b/plugins/http_endpoint_test.go @@ -72,8 +72,8 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() { tmpDir, err = os.MkdirTemp("", "http-endpoint-test-*") Expect(err).ToNot(HaveOccurred()) - // Copy both test plugins - for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public"} { + // Copy all test plugins + for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} { srcPath := filepath.Join(testdataDir, pluginName+PackageExtension) destPath := filepath.Join(tmpDir, pluginName+PackageExtension) data, err := os.ReadFile(srcPath) @@ -109,7 +109,7 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() { // Build enabled plugins list var enabledPlugins model.Plugins - for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public"} { + for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} { pluginPath := filepath.Join(tmpDir, pluginName+PackageExtension) data, err := os.ReadFile(pluginPath) Expect(err).ToNot(HaveOccurred()) @@ -162,6 +162,18 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() { Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue()) }) + It("loads the native auth endpoint plugin", func() { + manager.mu.RLock() + p := manager.plugins["test-http-endpoint-native"] + manager.mu.RUnlock() + + Expect(p).ToNot(BeNil()) + Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Native Plugin")) + Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil()) + Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("native")) + Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue()) + }) + It("loads the public endpoint plugin", func() { manager.mu.RLock() p := manager.plugins["test-http-endpoint-public"] @@ -239,6 +251,55 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() { }) }) + Describe("Native Auth Endpoints", func() { + It("returns hello response with valid native auth", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil) + req.Header.Set("X-Test-User", "testuser") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("Hello from native auth plugin!")) + Expect(w.Header().Get("Content-Type")).To(Equal("text/plain")) + }) + + It("returns echo response with user details", func() { + req := httptest.NewRequest("POST", "/test-http-endpoint-native/echo?foo=bar", strings.NewReader("native body")) + req.Header.Set("X-Test-User", "adminuser") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var resp map[string]any + err := json.Unmarshal(w.Body.Bytes(), &resp) + Expect(err).ToNot(HaveOccurred()) + Expect(resp["method"]).To(Equal("POST")) + Expect(resp["path"]).To(Equal("/echo")) + Expect(resp["body"]).To(Equal("native body")) + Expect(resp["hasUser"]).To(BeTrue()) + Expect(resp["username"]).To(Equal("adminuser")) + }) + + It("returns 401 without auth header", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("returns 401 with invalid auth header", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil) + req.Header.Set("X-Test-User", "nonexistent") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + Describe("Public Endpoints (auth: none)", func() { It("returns webhook response without auth", func() { req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil) diff --git a/plugins/manager.go b/plugins/manager.go index d8d4f28ef..c8521e6b0 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -260,19 +260,10 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { return nil, false } - // Build user ID map for fast lookups - userIDMap := make(map[string]struct{}) - for _, id := range plugin.allowedUserIDs { - userIDMap[id] = struct{}{} - } - - // Create a new scrobbler adapter for this plugin with user authorization config + // Create a new scrobbler adapter for this plugin return &ScrobblerPlugin{ - name: plugin.name, - plugin: plugin, - allowedUserIDs: plugin.allowedUserIDs, - allUsers: plugin.allUsers, - userIDMap: userIDMap, + name: plugin.name, + plugin: plugin, }, true } diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index b558da1be..5e8a1ff44 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -24,10 +24,9 @@ type serviceContext struct { manager *Manager permissions *Permissions config map[string]string - allowedUsers []string // User IDs this plugin can access - allUsers bool // If true, plugin can access all users - allowedLibraries []int // Library IDs this plugin can access - allLibraries bool // If true, plugin can access all libraries + userAccess UserAccess // User authorization for this plugin + allowedLibraries []int // Library IDs this plugin can access + allLibraries bool // If true, plugin can access all libraries } // hostServiceEntry defines a host service for table-driven registration. @@ -52,7 +51,7 @@ var hostServices = []hostServiceEntry{ name: "SubsonicAPI", hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil }, create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { - service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers) + service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.userAccess) return host.RegisterSubsonicAPIHostFunctions(service), nil }, }, @@ -115,7 +114,7 @@ var hostServices = []hostServiceEntry{ name: "Users", hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil }, create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { - service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers) + service := newUsersService(ctx.manager.ds, ctx.userAccess) return host.RegisterUsersHostFunctions(service), nil }, }, @@ -302,13 +301,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { var hostFunctions []extism.HostFunction var closers []io.Closer + userAccess := NewUserAccess(p.AllUsers, allowedUsers) + svcCtx := &serviceContext{ pluginName: p.ID, manager: m, permissions: pkg.Manifest.Permissions, config: pluginConfig, - allowedUsers: allowedUsers, - allUsers: p.AllUsers, + userAccess: userAccess, allowedLibraries: allowedLibraries, allLibraries: p.AllLibraries, } @@ -361,15 +361,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { m.mu.Lock() m.plugins[p.ID] = &plugin{ - name: p.ID, - path: p.Path, - manifest: pkg.Manifest, - compiled: compiled, - capabilities: capabilities, - closers: closers, - metrics: m.metrics, - allowedUserIDs: allowedUsers, - allUsers: p.AllUsers, + name: p.ID, + path: p.Path, + manifest: pkg.Manifest, + compiled: compiled, + capabilities: capabilities, + closers: closers, + metrics: m.metrics, + userAccess: userAccess, } m.mu.Unlock() diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 08c0073b6..3b2629731 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -12,15 +12,14 @@ import ( // plugin represents a loaded plugin type plugin struct { - name string // Plugin name (from filename) - path string // Path to the wasm file - manifest *Manifest - compiled *extism.CompiledPlugin - capabilities []Capability // Auto-detected capabilities based on exported functions - closers []io.Closer // Cleanup functions to call on unload - metrics PluginMetricsRecorder - allowedUserIDs []string // User IDs this plugin can access (from DB configuration) - allUsers bool // If true, plugin can access all users + name string // Plugin name (from filename) + path string // Path to the wasm file + manifest *Manifest + compiled *extism.CompiledPlugin + capabilities []Capability // Auto-detected capabilities based on exported functions + closers []io.Closer // Cleanup functions to call on unload + metrics PluginMetricsRecorder + userAccess UserAccess // User authorization for this plugin } // instance creates a new plugin instance for the given context. diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 874c6603a..fbfd6ce32 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -33,11 +33,8 @@ func init() { // ScrobblerPlugin is an adapter that wraps an Extism plugin and implements // the scrobbler.Scrobbler interface for scrobbling to external services. type ScrobblerPlugin struct { - name string - plugin *plugin - allowedUserIDs []string // User IDs this plugin can access (from DB configuration) - allUsers bool // If true, plugin can access all users - userIDMap map[string]struct{} // Cached map for fast lookups + name string + plugin *plugin } // IsAuthorized checks if the user is authorized with this scrobbler. @@ -45,7 +42,7 @@ type ScrobblerPlugin struct { // then delegates to the plugin for service-specific authorization. func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool { // First check server-side authorization based on plugin configuration - if !s.isUserAllowed(userId) { + if !s.plugin.userAccess.IsAllowed(userId) { return false } @@ -63,18 +60,6 @@ func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool return result } -// isUserAllowed checks if the given user ID is allowed to use this plugin. -func (s *ScrobblerPlugin) isUserAllowed(userId string) bool { - if s.allUsers { - return true - } - if len(s.allowedUserIDs) == 0 { - return false - } - _, ok := s.userIDMap[userId] - return ok -} - // NowPlaying sends a now playing notification to the scrobbler func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { username := getUsernameFromContext(ctx) diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 05fc11757..1dad53d5f 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -71,41 +71,6 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { }) }) - Describe("isUserAllowed", func() { - It("returns true when allUsers is true", func() { - sp := &ScrobblerPlugin{allUsers: true} - Expect(sp.isUserAllowed("any-user")).To(BeTrue()) - }) - - It("returns false when allowedUserIDs is empty and allUsers is false", func() { - sp := &ScrobblerPlugin{allUsers: false, allowedUserIDs: []string{}} - Expect(sp.isUserAllowed("user-1")).To(BeFalse()) - }) - - It("returns false when allowedUserIDs is nil and allUsers is false", func() { - sp := &ScrobblerPlugin{allUsers: false} - Expect(sp.isUserAllowed("user-1")).To(BeFalse()) - }) - - It("returns true when user is in allowedUserIDs", func() { - sp := &ScrobblerPlugin{ - allUsers: false, - allowedUserIDs: []string{"user-1", "user-2"}, - userIDMap: map[string]struct{}{"user-1": {}, "user-2": {}}, - } - Expect(sp.isUserAllowed("user-1")).To(BeTrue()) - }) - - It("returns false when user is not in allowedUserIDs", func() { - sp := &ScrobblerPlugin{ - allUsers: false, - allowedUserIDs: []string{"user-1", "user-2"}, - userIDMap: map[string]struct{}{"user-1": {}, "user-2": {}}, - } - Expect(sp.isUserAllowed("user-3")).To(BeFalse()) - }) - }) - Describe("NowPlaying", func() { It("successfully calls the plugin", func() { track := &model.MediaFile{ diff --git a/plugins/testdata/test-http-endpoint-native/go.mod b/plugins/testdata/test-http-endpoint-native/go.mod new file mode 100644 index 000000000..e6f6c1a68 --- /dev/null +++ b/plugins/testdata/test-http-endpoint-native/go.mod @@ -0,0 +1,16 @@ +module test-http-endpoint-native + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-http-endpoint-native/go.sum b/plugins/testdata/test-http-endpoint-native/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-http-endpoint-native/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-http-endpoint-native/main.go b/plugins/testdata/test-http-endpoint-native/main.go new file mode 100644 index 000000000..6a4cf6869 --- /dev/null +++ b/plugins/testdata/test-http-endpoint-native/main.go @@ -0,0 +1,61 @@ +// Test plugin for native auth (JWT) HTTP endpoint integration tests. +// Build with: tinygo build -o ../test-http-endpoint-native.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "encoding/json" + + "github.com/navidrome/navidrome/plugins/pdk/go/httpendpoint" +) + +func init() { + httpendpoint.Register(&testNativeEndpoint{}) +} + +type testNativeEndpoint struct{} + +func (t *testNativeEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpendpoint.HTTPHandleResponse, error) { + switch req.Path { + case "/hello": + return httpendpoint.HTTPHandleResponse{ + Status: 200, + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + }, + Body: "Hello from native auth plugin!", + }, nil + + case "/echo": + // Echo back the request as JSON + data, _ := json.Marshal(map[string]any{ + "method": req.Method, + "path": req.Path, + "query": req.Query, + "body": req.Body, + "hasUser": req.User != nil, + "username": userName(req.User), + }) + return httpendpoint.HTTPHandleResponse{ + Status: 200, + Headers: map[string][]string{ + "Content-Type": {"application/json"}, + }, + Body: string(data), + }, nil + + default: + return httpendpoint.HTTPHandleResponse{ + Status: 404, + Body: "Not found: " + req.Path, + }, nil + } +} + +func userName(u *httpendpoint.HTTPUser) string { + if u == nil { + return "" + } + return u.Username +} + +func main() {} diff --git a/plugins/testdata/test-http-endpoint-native/manifest.json b/plugins/testdata/test-http-endpoint-native/manifest.json new file mode 100644 index 000000000..364ac0cdf --- /dev/null +++ b/plugins/testdata/test-http-endpoint-native/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "Test HTTP Endpoint Native Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test plugin for native (JWT) HTTP endpoint integration testing", + "permissions": { + "endpoints": { + "auth": "native", + "paths": ["/hello", "/echo"], + "reason": "Testing native auth HTTP endpoint handling" + }, + "users": { + "reason": "Authenticated endpoints require user access" + } + } +} diff --git a/plugins/user_access.go b/plugins/user_access.go new file mode 100644 index 000000000..15e7fb354 --- /dev/null +++ b/plugins/user_access.go @@ -0,0 +1,35 @@ +package plugins + +// UserAccess encapsulates user authorization for a plugin, +// determining which users are allowed to interact with it. +type UserAccess struct { + allUsers bool + userIDMap map[string]struct{} +} + +// NewUserAccess creates a UserAccess from the plugin's configuration. +// If allUsers is true, all users are allowed regardless of the list. +func NewUserAccess(allUsers bool, userIDs []string) UserAccess { + userIDMap := make(map[string]struct{}, len(userIDs)) + for _, id := range userIDs { + userIDMap[id] = struct{}{} + } + return UserAccess{ + allUsers: allUsers, + userIDMap: userIDMap, + } +} + +// IsAllowed checks if the given user ID is permitted. +func (ua UserAccess) IsAllowed(userID string) bool { + if ua.allUsers { + return true + } + _, ok := ua.userIDMap[userID] + return ok +} + +// HasConfiguredUsers reports whether any specific user IDs have been configured. +func (ua UserAccess) HasConfiguredUsers() bool { + return ua.allUsers || len(ua.userIDMap) > 0 +} diff --git a/plugins/user_access_test.go b/plugins/user_access_test.go new file mode 100644 index 000000000..b1037e9b6 --- /dev/null +++ b/plugins/user_access_test.go @@ -0,0 +1,64 @@ +//go:build !windows + +package plugins + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("UserAccess", func() { + Describe("IsAllowed", func() { + It("returns true when allUsers is true", func() { + ua := NewUserAccess(true, nil) + Expect(ua.IsAllowed("any-user")).To(BeTrue()) + }) + + It("returns true when allUsers is true even with an explicit list", func() { + ua := NewUserAccess(true, []string{"user-1"}) + Expect(ua.IsAllowed("other-user")).To(BeTrue()) + }) + + It("returns false when userIDs is empty", func() { + ua := NewUserAccess(false, []string{}) + Expect(ua.IsAllowed("user-1")).To(BeFalse()) + }) + + It("returns false when userIDs is nil", func() { + ua := NewUserAccess(false, nil) + Expect(ua.IsAllowed("user-1")).To(BeFalse()) + }) + + It("returns true when user is in the list", func() { + ua := NewUserAccess(false, []string{"user-1", "user-2"}) + Expect(ua.IsAllowed("user-1")).To(BeTrue()) + }) + + It("returns false when user is not in the list", func() { + ua := NewUserAccess(false, []string{"user-1", "user-2"}) + Expect(ua.IsAllowed("user-3")).To(BeFalse()) + }) + }) + + Describe("HasConfiguredUsers", func() { + It("returns true when allUsers is true", func() { + ua := NewUserAccess(true, nil) + Expect(ua.HasConfiguredUsers()).To(BeTrue()) + }) + + It("returns true when specific users are configured", func() { + ua := NewUserAccess(false, []string{"user-1"}) + Expect(ua.HasConfiguredUsers()).To(BeTrue()) + }) + + It("returns false when no users are configured", func() { + ua := NewUserAccess(false, nil) + Expect(ua.HasConfiguredUsers()).To(BeFalse()) + }) + + It("returns false when user list is empty", func() { + ua := NewUserAccess(false, []string{}) + Expect(ua.HasConfiguredUsers()).To(BeFalse()) + }) + }) +}) From 5c52bbb1306abe354e92a5fa46c5bc43acd7f88f Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Feb 2026 12:57:30 -0500 Subject: [PATCH 3/8] refactor(auth): simplify authentication logic and improve error handling Signed-off-by: Deluan --- server/subsonic/middlewares.go | 140 +++++++++++++-------------------- 1 file changed, 55 insertions(+), 85 deletions(-) diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 0c701484d..97fc29fe5 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -25,7 +25,6 @@ import ( "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" ) @@ -95,54 +94,64 @@ func checkRequiredParameters(next http.Handler) http.Handler { }) } +// authenticateRequest validates the authentication credentials in an HTTP request and returns +// the authenticated user. It supports internal auth, reverse proxy auth, and Subsonic classic +// auth (username + password/token/salt/jwt query params). +// +// Callers should handle specific error types as needed: +// - context.Canceled: request was canceled during authentication +// - model.ErrNotFound: username not found in database +// - model.ErrInvalidAuth: invalid credentials (wrong password, token, etc.) +func authenticateRequest(ds model.DataStore, r *http.Request) (*model.User, error) { + ctx := r.Context() + + // Check internal auth or reverse proxy auth first + username, _ := fromInternalOrProxyAuth(r) + if username != "" { + return ds.User(ctx).FindByUsername(username) + } + + // Fall back to Subsonic classic auth (query params) + p := req.Params(r) + username, _ = p.String("u") + if username == "" { + return nil, model.ErrInvalidAuth + } + + pass, _ := p.String("p") + token, _ := p.String("t") + salt, _ := p.String("s") + jwt, _ := p.String("jwt") + + usr, err := ds.User(ctx).FindByUsernameWithPassword(username) + if err != nil { + return nil, err + } + + if err := validateCredentials(usr, pass, token, salt, jwt); err != nil { + return nil, err + } + + return usr, nil +} + func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - var usr *model.User - var err error - - username, isInternalAuth := fromInternalOrProxyAuth(r) - if username != "" { - authType := If(isInternalAuth, "internal", "reverse-proxy") - usr, err = ds.User(ctx).FindByUsername(username) - if errors.Is(err, context.Canceled) { - log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) - return - } - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "API: Invalid login", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) - } else if err != nil { - log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) - } - } else { - p := req.Params(r) - username, _ := p.String("u") - pass, _ := p.String("p") - token, _ := p.String("t") - salt, _ := p.String("s") - jwt, _ := p.String("jwt") - - usr, err = ds.User(ctx).FindByUsernameWithPassword(username) - if errors.Is(err, context.Canceled) { - log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - return - } - switch { - case errors.Is(err, model.ErrNotFound): - log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - case err != nil: - log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - default: - err = validateCredentials(usr, pass, token, salt, jwt) - if err != nil { - log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - } - } - } - + usr, err := authenticateRequest(ds, r) if err != nil { + username, _ := request.UsernameFrom(ctx) + switch { + case errors.Is(err, context.Canceled): + log.Debug(ctx, "API: Request canceled when authenticating", "username", username, "remoteAddr", r.RemoteAddr, err) + return + case errors.Is(err, model.ErrNotFound), errors.Is(err, model.ErrInvalidAuth): + log.Warn(ctx, "API: Invalid login", "username", username, "remoteAddr", r.RemoteAddr, err) + default: + log.Error(ctx, "API: Error authenticating", "username", username, "remoteAddr", r.RemoteAddr, err) + } sendError(w, r, newError(responses.ErrorAuthenticationFail)) return } @@ -157,13 +166,9 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { // Unlike the authenticate middleware, this function does not write any HTTP response, making it suitable // for use by external consumers (e.g., plugin endpoints) that need Subsonic auth but want to handle // errors themselves. -// -// It supports the same authentication methods as the Subsonic API: -// - Internal auth (from plugin SubsonicAPI calls) -// - Reverse proxy auth (via trusted external auth headers) -// - Subsonic classic auth (username + password/token/salt/jwt query params) func ValidateAuth(ds model.DataStore, r *http.Request) (*model.User, error) { - // Parse form data into query params (same as postFormToQueryParams middleware) + // Parse form data into query params (same as postFormToQueryParams middleware, + // which is not in the call chain when ValidateAuth is used directly) if err := r.ParseForm(); err != nil { return nil, fmt.Errorf("parsing form: %w", err) } @@ -175,42 +180,7 @@ func ValidateAuth(ds model.DataStore, r *http.Request) (*model.User, error) { } r.URL.RawQuery = strings.Join(parts, "&") - ctx := r.Context() - - // Check internal auth or reverse proxy auth first - username, isInternalAuth := fromInternalOrProxyAuth(r) - if username != "" { - usr, err := ds.User(ctx).FindByUsername(username) - if err != nil { - authType := If(isInternalAuth, "internal", "reverse-proxy") - log.Warn(ctx, "Plugin auth: Invalid login", "auth", authType, "username", username, err) - return nil, model.ErrInvalidAuth - } - return usr, nil - } - - // Fall back to Subsonic classic auth (query params) - p := req.Params(r) - username, _ = p.String("u") - if username == "" { - return nil, fmt.Errorf("missing required parameter 'u' (username)") - } - - pass, _ := p.String("p") - token, _ := p.String("t") - salt, _ := p.String("s") - jwt, _ := p.String("jwt") - - usr, err := ds.User(ctx).FindByUsernameWithPassword(username) - if err != nil { - return nil, model.ErrInvalidAuth - } - - if err := validateCredentials(usr, pass, token, salt, jwt); err != nil { - return nil, err - } - - return usr, nil + return authenticateRequest(ds, r) } func validateCredentials(user *model.User, pass, token, salt, jwt string) error { From 9a004fd043feb32dc172a02cd13e89f401d37c01 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Feb 2026 13:43:41 -0500 Subject: [PATCH 4/8] fix(plugins): correct Rust codegen serde attributes and harden endpoint responses Fix two issues from PR #5045 review. The Rust code generator was producing incorrect skip_serializing_if attributes: map types incorrectly used Option::is_none instead of HashMap::is_empty, and the bare int type for HTTPHandleResponse.Status fell through to the default Option::is_none case. The map fix is in skipSerializingFunc; the int issue is fixed at the source by changing Status from int to int32 (HTTP status codes always fit in int32, and this avoids platform-dependent int sizing on i386 vs amd64). Additionally, plugin HTTP responses now include forced security headers (X-Content-Type-Options: nosniff and a restrictive Content-Security-Policy with sandbox) to prevent XSS from compromised plugins serving HTML+JS on the same origin as Navidrome. --- plugins/capabilities/http_endpoint.go | 2 +- plugins/cmd/ndpgen/internal/generator.go | 5 ++- plugins/cmd/ndpgen/internal/generator_test.go | 8 +++-- plugins/http_endpoint.go | 8 +++-- plugins/http_endpoint_test.go | 32 +++++++++++++++++++ plugins/pdk/go/httpendpoint/httpendpoint.go | 3 +- .../pdk/go/httpendpoint/httpendpoint_stub.go | 3 +- .../nd-pdk-capabilities/src/httpendpoint.rs | 7 ++-- 8 files changed, 57 insertions(+), 11 deletions(-) diff --git a/plugins/capabilities/http_endpoint.go b/plugins/capabilities/http_endpoint.go index e5e7f984c..49e8cb81b 100644 --- a/plugins/capabilities/http_endpoint.go +++ b/plugins/capabilities/http_endpoint.go @@ -44,7 +44,7 @@ type HTTPUser struct { // HTTPHandleResponse is the response returned by the plugin's HandleRequest function. type HTTPHandleResponse struct { // Status is the HTTP status code. Defaults to 200 if zero or not set. - Status int `json:"status,omitempty"` + Status int32 `json:"status,omitempty"` // Headers contains the HTTP response headers to set. Headers map[string][]string `json:"headers,omitempty"` // Body is the response body content. diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 69e232565..6c5022eaf 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -560,9 +560,12 @@ func rustConstName(name string) string { // skipSerializingFunc returns the appropriate skip_serializing_if function name. func skipSerializingFunc(goType string) string { - if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") { + if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") { return "Option::is_none" } + if strings.HasPrefix(goType, "map[") { + return "HashMap::is_empty" + } switch goType { case "string": return "String::is_empty" diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index ed15f174b..542759ae5 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -1432,12 +1432,16 @@ type OnInitOutput struct { var _ = Describe("Rust Generation", func() { Describe("skipSerializingFunc", func() { - It("should return Option::is_none for pointer, slice, and map types", func() { + It("should return Option::is_none for pointer and slice types", func() { Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none")) Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none")) Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none")) Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none")) - Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none")) + }) + + It("should return HashMap::is_empty for map types", func() { + Expect(skipSerializingFunc("map[string]int")).To(Equal("HashMap::is_empty")) + Expect(skipSerializingFunc("map[string]string")).To(Equal("HashMap::is_empty")) }) It("should return String::is_empty for string type", func() { diff --git a/plugins/http_endpoint.go b/plugins/http_endpoint.go index 025ce249d..cdd1c29d3 100644 --- a/plugins/http_endpoint.go +++ b/plugins/http_endpoint.go @@ -164,15 +164,19 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl return } - // Write response headers + // Write response headers from plugin for key, values := range resp.Headers { for _, v := range values { w.Header().Add(key, v) } } + // Security hardening: override any plugin-set security headers + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox") + // Write status code (default to 200) - status := resp.Status + status := int(resp.Status) if status == 0 { status = http.StatusOK } diff --git a/plugins/http_endpoint_test.go b/plugins/http_endpoint_test.go index 1f6d3de97..2d88ed1f1 100644 --- a/plugins/http_endpoint_test.go +++ b/plugins/http_endpoint_test.go @@ -320,6 +320,38 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() { }) }) + Describe("Security Headers", func() { + It("includes security headers in authenticated endpoint responses", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff")) + Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")) + }) + + It("includes security headers in public endpoint responses", func() { + req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff")) + Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")) + }) + + It("overrides plugin-set security headers", func() { + req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser", strings.NewReader("body")) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff")) + Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")) + }) + }) + Describe("Unknown Plugin", func() { It("returns 404 for nonexistent plugin", func() { req := httptest.NewRequest("GET", "/nonexistent-plugin/hello", nil) diff --git a/plugins/pdk/go/httpendpoint/httpendpoint.go b/plugins/pdk/go/httpendpoint/httpendpoint.go index f927cf081..383b3f955 100644 --- a/plugins/pdk/go/httpendpoint/httpendpoint.go +++ b/plugins/pdk/go/httpendpoint/httpendpoint.go @@ -17,6 +17,7 @@ type HTTPHandleRequest struct { Method string `json:"method"` // Path is the request path relative to the plugin's base URL. // For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook". + // Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "". Path string `json:"path"` // Query is the raw query string without the leading '?'. Query string `json:"query,omitempty"` @@ -31,7 +32,7 @@ type HTTPHandleRequest struct { // HTTPHandleResponse is the response returned by the plugin's HandleRequest function. type HTTPHandleResponse struct { // Status is the HTTP status code. Defaults to 200 if zero or not set. - Status int `json:"status,omitempty"` + Status int32 `json:"status,omitempty"` // Headers contains the HTTP response headers to set. Headers map[string][]string `json:"headers,omitempty"` // Body is the response body content. diff --git a/plugins/pdk/go/httpendpoint/httpendpoint_stub.go b/plugins/pdk/go/httpendpoint/httpendpoint_stub.go index 7a97757f8..5343b8e78 100644 --- a/plugins/pdk/go/httpendpoint/httpendpoint_stub.go +++ b/plugins/pdk/go/httpendpoint/httpendpoint_stub.go @@ -14,6 +14,7 @@ type HTTPHandleRequest struct { Method string `json:"method"` // Path is the request path relative to the plugin's base URL. // For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook". + // Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "". Path string `json:"path"` // Query is the raw query string without the leading '?'. Query string `json:"query,omitempty"` @@ -28,7 +29,7 @@ type HTTPHandleRequest struct { // HTTPHandleResponse is the response returned by the plugin's HandleRequest function. type HTTPHandleResponse struct { // Status is the HTTP status code. Defaults to 200 if zero or not set. - Status int `json:"status,omitempty"` + Status int32 `json:"status,omitempty"` // Headers contains the HTTP response headers to set. Headers map[string][]string `json:"headers,omitempty"` // Body is the response body content. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs index 0d639033c..72cec1cb3 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs @@ -28,13 +28,14 @@ pub struct HTTPHandleRequest { pub method: String, /// Path is the request path relative to the plugin's base URL. /// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook". + /// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "". #[serde(default)] pub path: String, /// Query is the raw query string without the leading '?'. #[serde(default, skip_serializing_if = "String::is_empty")] pub query: String, /// Headers contains the HTTP request headers. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub headers: std::collections::HashMap>, /// Body is the request body content. #[serde(default, skip_serializing_if = "String::is_empty")] @@ -48,10 +49,10 @@ pub struct HTTPHandleRequest { #[serde(rename_all = "camelCase")] pub struct HTTPHandleResponse { /// Status is the HTTP status code. Defaults to 200 if zero or not set. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "is_zero_i32")] pub status: i32, /// Headers contains the HTTP response headers to set. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub headers: std::collections::HashMap>, /// Body is the response body content. #[serde(default, skip_serializing_if = "String::is_empty")] From b1a51f9bbe76248c6e9b4b86f56c00e40dcdec12 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Feb 2026 15:20:55 -0500 Subject: [PATCH 5/8] feat(plugins): add raw binary framing support for HTTP endpoint requests and responses Signed-off-by: Deluan --- plugins/capabilities/http_endpoint.go | 6 +- plugins/capabilities/http_endpoint.yaml | 81 +++++++++++ plugins/cmd/ndpgen/internal/generator.go | 25 ++++ plugins/cmd/ndpgen/internal/generator_test.go | 4 + plugins/cmd/ndpgen/internal/parser.go | 1 + plugins/cmd/ndpgen/internal/parser_test.go | 62 ++++++++ .../internal/templates/capability.go.tmpl | 57 ++++++++ .../internal/templates/capability.rs.tmpl | 111 +++++++++++++- .../templates/capability_stub.go.tmpl | 5 + plugins/cmd/ndpgen/internal/types.go | 11 ++ plugins/cmd/ndpgen/internal/xtp_schema.go | 70 ++++++++- .../cmd/ndpgen/internal/xtp_schema_test.go | 135 ++++++++++++++++++ plugins/http_endpoint.go | 14 +- plugins/http_endpoint_test.go | 13 ++ plugins/manager_call.go | 100 +++++++++++++ plugins/pdk/go/go.mod | 7 + plugins/pdk/go/httpendpoint/httpendpoint.go | 43 ++++-- .../pdk/go/httpendpoint/httpendpoint_stub.go | 4 +- .../nd-pdk-capabilities/src/httpendpoint.rs | 50 +++++-- .../test-http-endpoint-native/main.go | 8 +- .../test-http-endpoint-public/main.go | 6 +- plugins/testdata/test-http-endpoint/main.go | 20 ++- 22 files changed, 790 insertions(+), 43 deletions(-) create mode 100644 plugins/capabilities/http_endpoint.yaml diff --git a/plugins/capabilities/http_endpoint.go b/plugins/capabilities/http_endpoint.go index 49e8cb81b..320150f8f 100644 --- a/plugins/capabilities/http_endpoint.go +++ b/plugins/capabilities/http_endpoint.go @@ -7,7 +7,7 @@ package capabilities //nd:capability name=httpendpoint required=true type HTTPEndpoint interface { // HandleRequest processes an incoming HTTP request and returns a response. - //nd:export name=nd_http_handle_request + //nd:export name=nd_http_handle_request raw=true HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error) } @@ -24,7 +24,7 @@ type HTTPHandleRequest struct { // Headers contains the HTTP request headers. Headers map[string][]string `json:"headers,omitempty"` // Body is the request body content. - Body string `json:"body,omitempty"` + Body []byte `json:"body,omitempty"` // User contains the authenticated user information. Nil for auth:"none" endpoints. User *HTTPUser `json:"user,omitempty"` } @@ -48,5 +48,5 @@ type HTTPHandleResponse struct { // Headers contains the HTTP response headers to set. Headers map[string][]string `json:"headers,omitempty"` // Body is the response body content. - Body string `json:"body,omitempty"` + Body []byte `json:"body,omitempty"` } diff --git a/plugins/capabilities/http_endpoint.yaml b/plugins/capabilities/http_endpoint.yaml new file mode 100644 index 000000000..7c6468b09 --- /dev/null +++ b/plugins/capabilities/http_endpoint.yaml @@ -0,0 +1,81 @@ +version: v1-draft +exports: + nd_http_handle_request: + description: HandleRequest processes an incoming HTTP request and returns a response. + input: + $ref: '#/components/schemas/HTTPHandleRequest' + contentType: application/json + output: + $ref: '#/components/schemas/HTTPHandleResponse' + contentType: application/json +components: + schemas: + HTTPHandleRequest: + description: HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin. + properties: + method: + type: string + description: Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.). + path: + type: string + description: |- + Path is the request path relative to the plugin's base URL. + For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook". + Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "". + query: + type: string + description: Query is the raw query string without the leading '?'. + headers: + type: object + description: Headers contains the HTTP request headers. + additionalProperties: + type: array + items: + type: string + body: + type: buffer + description: Body is the request body content. + user: + $ref: '#/components/schemas/HTTPUser' + description: User contains the authenticated user information. Nil for auth:"none" endpoints. + nullable: true + required: + - method + - path + HTTPHandleResponse: + description: HTTPHandleResponse is the response returned by the plugin's HandleRequest function. + properties: + status: + type: integer + format: int32 + description: Status is the HTTP status code. Defaults to 200 if zero or not set. + headers: + type: object + description: Headers contains the HTTP response headers to set. + additionalProperties: + type: array + items: + type: string + body: + type: buffer + description: Body is the response body content. + HTTPUser: + description: HTTPUser contains authenticated user information passed to the plugin. + properties: + id: + type: string + description: ID is the internal Navidrome user ID. + username: + type: string + description: Username is the user's login name. + name: + type: string + description: Name is the user's display name. + isAdmin: + type: boolean + description: IsAdmin indicates whether the user has admin privileges. + required: + - id + - username + - name + - isAdmin diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 6c5022eaf..352f72cda 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -364,6 +364,27 @@ func capabilityFuncMap(cap Capability) template.FuncMap { "providerInterface": func(e Export) string { return e.ProviderInterfaceName() }, "implVar": func(e Export) string { return e.ImplVarName() }, "exportFunc": func(e Export) string { return e.ExportFuncName() }, + "rawFieldName": rawFieldName(cap), + } +} + +// rawFieldName returns a template function that finds the first []byte field name +// in a struct by type name. This is used by raw export templates to generate +// field-specific binary frame code. +func rawFieldName(cap Capability) func(string) string { + structMap := make(map[string]StructDef) + for _, s := range cap.Structs { + structMap[s.Name] = s + } + return func(typeName string) string { + if s, ok := structMap[typeName]; ok { + for _, f := range s.Fields { + if f.Type == "[]byte" { + return f.Name + } + } + } + return "" } } @@ -466,6 +487,7 @@ func rustCapabilityFuncMap(cap Capability) template.FuncMap { "providerInterface": func(e Export) string { return e.ProviderInterfaceName() }, "registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) }, "snakeCase": ToSnakeCase, + "rawFieldName": rawFieldName(cap), "indent": func(spaces int, s string) string { indent := strings.Repeat(" ", spaces) lines := strings.Split(s, "\n") @@ -560,6 +582,9 @@ func rustConstName(name string) string { // skipSerializingFunc returns the appropriate skip_serializing_if function name. func skipSerializingFunc(goType string) string { + if goType == "[]byte" { + return "Vec::is_empty" + } if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") { return "Option::is_none" } diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 542759ae5..89912d48c 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -1432,6 +1432,10 @@ type OnInitOutput struct { var _ = Describe("Rust Generation", func() { Describe("skipSerializingFunc", func() { + It("should return Vec::is_empty for []byte type", func() { + Expect(skipSerializingFunc("[]byte")).To(Equal("Vec::is_empty")) + }) + It("should return Option::is_none for pointer and slice types", func() { Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none")) Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none")) diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index c2d571779..f7279d941 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -269,6 +269,7 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri Name: name, ExportName: annotation["name"], Doc: doc, + Raw: annotation["raw"] == "true", } // Capability exports have exactly one input parameter (the struct type) diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go index f2bdbeded..cb9848e7c 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -635,6 +635,68 @@ type Output struct { }) }) + Describe("ParseCapabilities raw=true", func() { + It("should parse raw=true export annotation", func() { + src := `package capabilities + +//nd:capability name=httpendpoint required=true +type HTTPEndpoint interface { + //nd:export name=nd_http_handle_request raw=true + HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error) +} + +type HTTPHandleRequest struct { + Method string ` + "`json:\"method\"`" + ` + Body []byte ` + "`json:\"body,omitempty\"`" + ` +} + +type HTTPHandleResponse struct { + Status int32 ` + "`json:\"status,omitempty\"`" + ` + Body []byte ` + "`json:\"body,omitempty\"`" + ` +} +` + err := os.WriteFile(filepath.Join(tmpDir, "http_endpoint.go"), []byte(src), 0600) + Expect(err).NotTo(HaveOccurred()) + + capabilities, err := ParseCapabilities(tmpDir) + Expect(err).NotTo(HaveOccurred()) + Expect(capabilities).To(HaveLen(1)) + + cap := capabilities[0] + Expect(cap.Methods).To(HaveLen(1)) + Expect(cap.Methods[0].Raw).To(BeTrue()) + Expect(cap.HasRawMethods()).To(BeTrue()) + }) + + It("should default Raw to false for export annotations without raw", func() { + src := `package capabilities + +//nd:capability name=test required=true +type TestCapability interface { + //nd:export name=nd_test + Test(TestInput) (TestOutput, error) +} + +type TestInput struct { + Value string ` + "`json:\"value\"`" + ` +} + +type TestOutput struct { + Result string ` + "`json:\"result\"`" + ` +} +` + err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) + Expect(err).NotTo(HaveOccurred()) + + capabilities, err := ParseCapabilities(tmpDir) + Expect(err).NotTo(HaveOccurred()) + Expect(capabilities).To(HaveLen(1)) + + Expect(capabilities[0].Methods[0].Raw).To(BeFalse()) + Expect(capabilities[0].HasRawMethods()).To(BeFalse()) + }) + }) + Describe("Export helpers", func() { It("should generate correct provider interface name", func() { e := Export{Name: "GetArtistBiography"} diff --git a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl index ebcd80739..a9872478b 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl @@ -9,6 +9,10 @@ package {{.Package}} import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +{{- if .Capability.HasRawMethods}} + "encoding/binary" + "encoding/json" +{{- end}} ) {{- /* Generate type alias definitions */ -}} @@ -56,6 +60,7 @@ func (e {{$typeName}}) Error() string { return string(e) } {{- end}} {{- /* Generate struct definitions */ -}} +{{- $capability := .Capability}} {{- range .Capability.Structs}} {{- if .Doc}} @@ -68,8 +73,12 @@ type {{.Name}} struct { {{- if .Doc}} {{formatDoc .Doc | indent 1}} {{- end}} +{{- if and (eq .Type "[]byte") $capability.HasRawMethods}} + {{.Name}} {{.Type}} `json:"-"` +{{- else}} {{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"` {{- end}} +{{- end}} } {{- end}} @@ -172,6 +181,53 @@ func {{exportFunc .}}() int32 { // Return standard code - host will skip this plugin gracefully return NotImplementedCode } +{{- if .Raw}} +{{- /* Raw binary frame input/output */ -}} +{{- if .HasInput}} + + // Parse input frame: [json_len:4B][JSON without []byte field][raw bytes] + raw := pdk.Input() + if len(raw) < 4 { + pdk.SetErrorString("malformed input frame") + return -1 + } + jsonLen := binary.BigEndian.Uint32(raw[:4]) + if uint32(len(raw)-4) < jsonLen { + pdk.SetErrorString("invalid json length in input frame") + return -1 + } + var input {{.Input.Type}} + if err := json.Unmarshal(raw[4:4+jsonLen], &input); err != nil { + pdk.SetError(err) + return -1 + } + input.{{rawFieldName .Input.Type}} = raw[4+jsonLen:] +{{- end}} +{{- if and .HasInput .HasOutput}} + + output, err := {{implVar .}}(input) + if err != nil { + // Error frame: [0x01][UTF-8 error message] + errMsg := []byte(err.Error()) + errFrame := make([]byte, 1+len(errMsg)) + errFrame[0] = 0x01 + copy(errFrame[1:], errMsg) + pdk.Output(errFrame) + return 0 + } + + // Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes] + jsonBytes, _ := json.Marshal(output) + rawBytes := output.{{rawFieldName .Output.Type}} + frame := make([]byte, 1+4+len(jsonBytes)+len(rawBytes)) + frame[0] = 0x00 + binary.BigEndian.PutUint32(frame[1:5], uint32(len(jsonBytes))) + copy(frame[5:5+len(jsonBytes)], jsonBytes) + copy(frame[5+len(jsonBytes):], rawBytes) + pdk.Output(frame) +{{- end}} +{{- else}} +{{- /* Standard JSON input/output */ -}} {{- if .HasInput}} var input {{.Input.Type}} @@ -216,6 +272,7 @@ func {{exportFunc .}}() int32 { pdk.SetError(err) return -1 } +{{- end}} {{- end}} return 0 diff --git a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl index 597a17338..c8459bbe2 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl @@ -52,6 +52,7 @@ pub const {{rustConstName $v.Name}}: &'static str = {{$v.Value}}; {{- end}} {{- /* Generate struct definitions */ -}} +{{- $capability := .Capability}} {{- range .Capability.Structs}} {{- if .Doc}} @@ -66,13 +67,17 @@ pub struct {{.Name}} { {{- if .Doc}} {{rustDocComment .Doc | indent 4}} {{- end}} -{{- if .OmitEmpty}} +{{- if and (eq .Type "[]byte") $capability.HasRawMethods}} + #[serde(skip)] + pub {{rustFieldName .Name}}: {{fieldRustType .}}, +{{- else if .OmitEmpty}} #[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")] + pub {{rustFieldName .Name}}: {{fieldRustType .}}, {{- else}} #[serde(default)] -{{- end}} pub {{rustFieldName .Name}}: {{fieldRustType .}}, {{- end}} +{{- end}} } {{- end}} @@ -124,6 +129,56 @@ pub trait {{agentName .Capability}} { macro_rules! register_{{snakeCase .Package}} { ($plugin_type:ty) => { {{- range .Capability.Methods}} + {{- if .Raw}} + #[extism_pdk::plugin_fn] + pub fn {{.ExportName}}( + {{- if .HasInput}} + _raw_input: extism_pdk::Raw> + {{- end}} + ) -> extism_pdk::FnResult>> { + let plugin = <$plugin_type>::default(); + {{- if .HasInput}} + // Parse input frame: [json_len:4B][JSON without []byte field][raw bytes] + let raw_bytes = _raw_input.0; + if raw_bytes.len() < 4 { + let mut err_frame = vec![0x01u8]; + err_frame.extend_from_slice(b"malformed input frame"); + return Ok(extism_pdk::Raw(err_frame)); + } + let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize; + if json_len > raw_bytes.len() - 4 { + let mut err_frame = vec![0x01u8]; + err_frame.extend_from_slice(b"invalid json length in input frame"); + return Ok(extism_pdk::Raw(err_frame)); + } + let mut req: $crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}} = serde_json::from_slice(&raw_bytes[4..4+json_len]) + .map_err(|e| extism_pdk::Error::msg(e.to_string()))?; + req.{{rustFieldName (rawFieldName .Input.Type)}} = raw_bytes[4+json_len..].to_vec(); + {{- end}} + {{- if and .HasInput .HasOutput}} + match $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req) { + Ok(output) => { + // Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes] + let json_bytes = serde_json::to_vec(&output) + .map_err(|e| extism_pdk::Error::msg(e.to_string()))?; + let raw_field = &output.{{rustFieldName (rawFieldName .Output.Type)}}; + let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len()); + frame.push(0x00); + frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes()); + frame.extend_from_slice(&json_bytes); + frame.extend_from_slice(raw_field); + Ok(extism_pdk::Raw(frame)) + } + Err(e) => { + // Error frame: [0x01][UTF-8 error message] + let mut err_frame = vec![0x01u8]; + err_frame.extend_from_slice(e.message.as_bytes()); + Ok(extism_pdk::Raw(err_frame)) + } + } + {{- end}} + } + {{- else}} #[extism_pdk::plugin_fn] pub fn {{.ExportName}}( {{- if .HasInput}} @@ -146,6 +201,7 @@ macro_rules! register_{{snakeCase .Package}} { {{- end}} } {{- end}} + {{- end}} }; } {{- else}} @@ -171,6 +227,56 @@ pub trait {{providerInterface .}} { #[macro_export] macro_rules! {{registerMacroName .Name}} { ($plugin_type:ty) => { + {{- if .Raw}} + #[extism_pdk::plugin_fn] + pub fn {{.ExportName}}( + {{- if .HasInput}} + _raw_input: extism_pdk::Raw> + {{- end}} + ) -> extism_pdk::FnResult>> { + let plugin = <$plugin_type>::default(); + {{- if .HasInput}} + // Parse input frame: [json_len:4B][JSON without []byte field][raw bytes] + let raw_bytes = _raw_input.0; + if raw_bytes.len() < 4 { + let mut err_frame = vec![0x01u8]; + err_frame.extend_from_slice(b"malformed input frame"); + return Ok(extism_pdk::Raw(err_frame)); + } + let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize; + if json_len > raw_bytes.len() - 4 { + let mut err_frame = vec![0x01u8]; + err_frame.extend_from_slice(b"invalid json length in input frame"); + return Ok(extism_pdk::Raw(err_frame)); + } + let mut req: $crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}} = serde_json::from_slice(&raw_bytes[4..4+json_len]) + .map_err(|e| extism_pdk::Error::msg(e.to_string()))?; + req.{{rustFieldName (rawFieldName .Input.Type)}} = raw_bytes[4+json_len..].to_vec(); + {{- end}} + {{- if and .HasInput .HasOutput}} + match $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req) { + Ok(output) => { + // Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes] + let json_bytes = serde_json::to_vec(&output) + .map_err(|e| extism_pdk::Error::msg(e.to_string()))?; + let raw_field = &output.{{rustFieldName (rawFieldName .Output.Type)}}; + let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len()); + frame.push(0x00); + frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes()); + frame.extend_from_slice(&json_bytes); + frame.extend_from_slice(raw_field); + Ok(extism_pdk::Raw(frame)) + } + Err(e) => { + // Error frame: [0x01][UTF-8 error message] + let mut err_frame = vec![0x01u8]; + err_frame.extend_from_slice(e.message.as_bytes()); + Ok(extism_pdk::Raw(err_frame)) + } + } + {{- end}} + } + {{- else}} #[extism_pdk::plugin_fn] pub fn {{.ExportName}}( {{- if .HasInput}} @@ -192,6 +298,7 @@ macro_rules! {{registerMacroName .Name}} { Ok(()) {{- end}} } + {{- end}} }; } {{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl index 90f72be93..67417f67a 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl @@ -53,6 +53,7 @@ func (e {{$typeName}}) Error() string { return string(e) } {{- end}} {{- /* Generate struct definitions */ -}} +{{- $capability := .Capability}} {{- range .Capability.Structs}} {{- if .Doc}} @@ -65,8 +66,12 @@ type {{.Name}} struct { {{- if .Doc}} {{formatDoc .Doc | indent 1}} {{- end}} +{{- if and (eq .Type "[]byte") $capability.HasRawMethods}} + {{.Name}} {{.Type}} `json:"-"` +{{- else}} {{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"` {{- end}} +{{- end}} } {{- end}} diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go index 29cf2316a..13d6a4070 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -48,6 +48,16 @@ type ConstDef struct { Doc string // Documentation comment } +// HasRawMethods returns true if any export in the capability uses raw binary framing. +func (c Capability) HasRawMethods() bool { + for _, m := range c.Methods { + if m.Raw { + return true + } + } + return false +} + // KnownStructs returns a map of struct names defined in this capability. func (c Capability) KnownStructs() map[string]bool { result := make(map[string]bool) @@ -64,6 +74,7 @@ type Export struct { Input Param // Single input parameter (the struct type) Output Param // Single output return value (the struct type) Doc string // Documentation comment for the method + Raw bool // If true, uses binary framing instead of JSON for []byte fields } // ProviderInterfaceName returns the optional provider interface name. diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go index db30262cc..357a9d40f 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -54,6 +54,14 @@ type ( Nullable bool `yaml:"nullable,omitempty"` Items *xtpProperty `yaml:"items,omitempty"` } + + // xtpMapProperty represents a map property in XTP (type: object with additionalProperties). + xtpMapProperty struct { + Type string `yaml:"type"` + Description string `yaml:"description,omitempty"` + Nullable bool `yaml:"nullable,omitempty"` + AdditionalProperties *xtpProperty `yaml:"additionalProperties"` + } ) // GenerateSchema generates an XTP YAML schema from a capability. @@ -206,7 +214,12 @@ func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema for _, field := range st.Fields { propName := getJSONFieldName(field) - addToMap(&schema.Properties, propName, buildProperty(field, knownTypes)) + goType := strings.TrimPrefix(field.Type, "*") + if strings.HasPrefix(goType, "map[") { + addToMap(&schema.Properties, propName, buildMapProperty(goType, field.Doc, strings.HasPrefix(field.Type, "*"), knownTypes)) + } else { + addToMap(&schema.Properties, propName, buildProperty(field, knownTypes)) + } if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty { schema.Required = append(schema.Required, propName) @@ -246,6 +259,12 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { return prop } + // Handle []byte as buffer type (must be checked before generic slice handling) + if goType == "[]byte" { + prop.Type = "buffer" + return prop + } + // Handle slice types if strings.HasPrefix(goType, "[]") { elemType := goType[2:] @@ -264,6 +283,55 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { return prop } +// buildMapProperty builds an XTP MapProperty for a Go map type. +// It parses map[K]V and generates additionalProperties describing V. +func buildMapProperty(goType, doc string, isPointer bool, knownTypes map[string]bool) xtpMapProperty { + prop := xtpMapProperty{ + Type: "object", + Description: cleanDocForYAML(doc), + Nullable: isPointer, + } + + // Parse value type from map[K]V + valueType := parseMapValueType(goType) + + valProp := &xtpProperty{} + if strings.HasPrefix(valueType, "[]") { + elemType := valueType[2:] + valProp.Type = "array" + valProp.Items = &xtpProperty{} + if isKnownType(elemType, knownTypes) { + valProp.Items.Ref = "#/components/schemas/" + elemType + } else { + valProp.Items.Type = goTypeToXTPType(elemType) + } + } else if isKnownType(valueType, knownTypes) { + valProp.Ref = "#/components/schemas/" + valueType + } else { + valProp.Type, valProp.Format = goTypeToXTPTypeAndFormat(valueType) + } + prop.AdditionalProperties = valProp + + return prop +} + +// parseMapValueType extracts the value type from a Go map type string like "map[string][]string". +func parseMapValueType(goType string) string { + // Find the closing bracket of the key type + depth := 0 + for i, ch := range goType { + if ch == '[' { + depth++ + } else if ch == ']' { + depth-- + if depth == 0 { + return goType[i+1:] + } + } + } + return "object" // fallback +} + // addToMap adds a key-value pair to a yaml.Node map, preserving insertion order. func addToMap[T any](node *yaml.Node, key string, value T) { var valNode yaml.Node diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_test.go b/plugins/cmd/ndpgen/internal/xtp_schema_test.go index 5e8a132f2..debf3d5ed 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema_test.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema_test.go @@ -719,4 +719,139 @@ var _ = Describe("XTP Schema Generation", func() { Expect(schemas).NotTo(HaveKey("UnusedStatus")) }) }) + + Describe("GenerateSchema with []byte fields", func() { + It("should render []byte as buffer type and validate against XTP JSONSchema", func() { + capability := Capability{ + Name: "buffer_test", + SourceFile: "buffer_test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + { + Name: "Input", + Fields: []FieldDef{ + {Name: "Name", Type: "string", JSONTag: "name"}, + {Name: "Data", Type: "[]byte", JSONTag: "data,omitempty", OmitEmpty: true}, + }, + }, + { + Name: "Output", + Fields: []FieldDef{ + {Name: "Body", Type: "[]byte", JSONTag: "body,omitempty", OmitEmpty: true}, + }, + }, + }, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + Expect(ValidateXTPSchema(schema)).To(Succeed()) + + doc := parseSchema(schema) + components := doc["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) + input := schemas["Input"].(map[string]any) + props := input["properties"].(map[string]any) + data := props["data"].(map[string]any) + Expect(data["type"]).To(Equal("buffer")) + Expect(data).NotTo(HaveKey("items")) + Expect(data).NotTo(HaveKey("format")) + + output := schemas["Output"].(map[string]any) + outProps := output["properties"].(map[string]any) + body := outProps["body"].(map[string]any) + Expect(body["type"]).To(Equal("buffer")) + }) + }) + + Describe("GenerateSchema with map fields", func() { + It("should render map[string][]string as object with additionalProperties and validate", func() { + capability := Capability{ + Name: "map_test", + SourceFile: "map_test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + { + Name: "Input", + Fields: []FieldDef{ + {Name: "Headers", Type: "map[string][]string", JSONTag: "headers,omitempty", OmitEmpty: true}, + }, + }, + { + Name: "Output", + Fields: []FieldDef{ + {Name: "Value", Type: "string", JSONTag: "value"}, + }, + }, + }, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + Expect(ValidateXTPSchema(schema)).To(Succeed()) + + doc := parseSchema(schema) + components := doc["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) + input := schemas["Input"].(map[string]any) + props := input["properties"].(map[string]any) + headers := props["headers"].(map[string]any) + Expect(headers).To(HaveKey("additionalProperties")) + addlProps := headers["additionalProperties"].(map[string]any) + Expect(addlProps["type"]).To(Equal("array")) + items := addlProps["items"].(map[string]any) + Expect(items["type"]).To(Equal("string")) + }) + + It("should render map[string]string as object with string additionalProperties", func() { + capability := Capability{ + Name: "map_string_test", + SourceFile: "map_string_test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + { + Name: "Input", + Fields: []FieldDef{ + {Name: "Metadata", Type: "map[string]string", JSONTag: "metadata,omitempty", OmitEmpty: true}, + }, + }, + { + Name: "Output", + Fields: []FieldDef{ + {Name: "Value", Type: "string", JSONTag: "value"}, + }, + }, + }, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + Expect(ValidateXTPSchema(schema)).To(Succeed()) + + doc := parseSchema(schema) + components := doc["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) + input := schemas["Input"].(map[string]any) + props := input["properties"].(map[string]any) + metadata := props["metadata"].(map[string]any) + Expect(metadata).To(HaveKey("additionalProperties")) + addlProps := metadata["additionalProperties"].(map[string]any) + Expect(addlProps["type"]).To(Equal("string")) + }) + }) + + Describe("parseMapValueType", func() { + DescribeTable("should extract value type from Go map types", + func(goType, wantValue string) { + Expect(parseMapValueType(goType)).To(Equal(wantValue)) + }, + Entry("map[string]string", "map[string]string", "string"), + Entry("map[string]int", "map[string]int", "int"), + Entry("map[string][]string", "map[string][]string", "[]string"), + Entry("map[string][]byte", "map[string][]byte", "[]byte"), + ) + }) }) diff --git a/plugins/http_endpoint.go b/plugins/http_endpoint.go index cdd1c29d3..d0fc96c49 100644 --- a/plugins/http_endpoint.go +++ b/plugins/http_endpoint.go @@ -150,13 +150,15 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl Path: relPath, Query: r.URL.RawQuery, Headers: r.Header, - Body: string(body), + Body: body, User: httpUser, } - // Call the plugin - resp, err := callPluginFunction[capabilities.HTTPHandleRequest, capabilities.HTTPHandleResponse]( - ctx, p, FuncHTTPHandleRequest, pluginReq, + // Call the plugin using binary framing for []byte Body fields + resp, err := callPluginFunctionRaw( + ctx, p, FuncHTTPHandleRequest, + pluginReq, pluginReq.Body, + func(r *capabilities.HTTPHandleResponse, raw []byte) { r.Body = raw }, ) if err != nil { log.Error(ctx, "Plugin endpoint call failed", "plugin", p.name, "path", relPath, err) @@ -183,8 +185,8 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl w.WriteHeader(status) // Write response body - if resp.Body != "" { - if _, err := w.Write([]byte(resp.Body)); err != nil { + if len(resp.Body) > 0 { + if _, err := w.Write(resp.Body); err != nil { log.Error(ctx, "Failed to write plugin endpoint response", "plugin", p.name, err) } } diff --git a/plugins/http_endpoint_test.go b/plugins/http_endpoint_test.go index 2d88ed1f1..427543b40 100644 --- a/plugins/http_endpoint_test.go +++ b/plugins/http_endpoint_test.go @@ -446,6 +446,19 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() { }) }) + Describe("Binary Response", func() { + It("returns raw binary data intact", func() { + req := httptest.NewRequest("GET", "/test-http-endpoint/binary?u=testuser", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("image/png")) + // PNG header bytes + Expect(w.Body.Bytes()).To(Equal([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})) + }) + }) + Describe("Request body handling", func() { It("passes request body to the plugin", func() { body := `{"event":"push","ref":"refs/heads/main"}` diff --git a/plugins/manager_call.go b/plugins/manager_call.go index b5c7536b6..12729767c 100644 --- a/plugins/manager_call.go +++ b/plugins/manager_call.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "encoding/binary" "encoding/json" "errors" "fmt" @@ -95,6 +96,105 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcN return result, err } +// callPluginFunctionRaw calls a plugin function using binary framing for []byte fields. +// The input is JSON-encoded (with []byte field excluded via json:"-"), followed by raw bytes. +// The output frame is: [status:1B][json_len:4B][JSON][raw bytes] for success (0x00), +// or [0x01][UTF-8 error message] for errors. +func callPluginFunctionRaw[I any, O any]( + ctx context.Context, plugin *plugin, funcName string, + input I, rawInputBytes []byte, + setRawOutput func(*O, []byte), +) (O, error) { + start := time.Now() + + var result O + + p, err := plugin.instance(ctx) + if err != nil { + return result, fmt.Errorf("failed to create plugin: %w", err) + } + defer p.Close(ctx) + + if !p.FunctionExists(funcName) { + log.Trace(ctx, "Plugin function not found", "plugin", plugin.name, "function", funcName) + return result, fmt.Errorf("%w: %s", errFunctionNotFound, funcName) + } + + // Build input frame: [json_len:4B][JSON][raw bytes] + jsonBytes, err := json.Marshal(input) + if err != nil { + return result, fmt.Errorf("failed to marshal input: %w", err) + } + frame := make([]byte, 4+len(jsonBytes)+len(rawInputBytes)) + binary.BigEndian.PutUint32(frame[:4], uint32(len(jsonBytes))) + copy(frame[4:4+len(jsonBytes)], jsonBytes) + copy(frame[4+len(jsonBytes):], rawInputBytes) + + startCall := time.Now() + exit, output, err := p.CallWithContext(ctx, funcName, frame) + elapsed := time.Since(startCall) + if err != nil { + if ctx.Err() != nil { + log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed) + return result, ctx.Err() + } + plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) + log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err) + return result, fmt.Errorf("plugin call failed: %w", err) + } + if exit != 0 { + if exit == notImplementedCode { + log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start)) + return result, fmt.Errorf("%w: %s", errNotImplemented, funcName) + } + plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) + return result, fmt.Errorf("plugin call exited with code %d", exit) + } + + // Parse output frame + if len(output) < 1 { + plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) + return result, fmt.Errorf("empty response from plugin") + } + + statusByte := output[0] + if statusByte == 0x01 { + // Error frame: [0x01][UTF-8 error message] + plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) + errMsg := string(output[1:]) + return result, fmt.Errorf("plugin error: %s", errMsg) + } + + if statusByte != 0x00 { + plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) + return result, fmt.Errorf("unknown response status byte: 0x%02x", statusByte) + } + + // Success frame: [0x00][json_len:4B][JSON][raw bytes] + if len(output) < 5 { + plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) + return result, fmt.Errorf("malformed success response from plugin") + } + + jsonLen := binary.BigEndian.Uint32(output[1:5]) + if uint32(len(output)-5) < jsonLen { + plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) + return result, fmt.Errorf("invalid json length in response frame: %d exceeds available %d bytes", jsonLen, len(output)-5) + } + jsonData := output[5 : 5+jsonLen] + rawData := output[5+jsonLen:] + + if err := json.Unmarshal(jsonData, &result); err != nil { + plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) + return result, fmt.Errorf("failed to unmarshal response: %w", err) + } + setRawOutput(&result, rawData) + + plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, true, elapsed.Milliseconds()) + log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start)) + return result, nil +} + // extismLogger is a helper to log messages from Extism plugins func extismLogger(pluginName string) func(level extism.LogLevel, msg string) { return func(level extism.LogLevel, msg string) { diff --git a/plugins/pdk/go/go.mod b/plugins/pdk/go/go.mod index 3916cd749..4d5fcddfc 100644 --- a/plugins/pdk/go/go.mod +++ b/plugins/pdk/go/go.mod @@ -6,3 +6,10 @@ require ( github.com/extism/go-pdk v1.1.3 github.com/stretchr/testify v1.11.1 ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugins/pdk/go/httpendpoint/httpendpoint.go b/plugins/pdk/go/httpendpoint/httpendpoint.go index 383b3f955..b91b392aa 100644 --- a/plugins/pdk/go/httpendpoint/httpendpoint.go +++ b/plugins/pdk/go/httpendpoint/httpendpoint.go @@ -8,6 +8,9 @@ package httpendpoint import ( + "encoding/binary" + "encoding/json" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) @@ -24,7 +27,7 @@ type HTTPHandleRequest struct { // Headers contains the HTTP request headers. Headers map[string][]string `json:"headers,omitempty"` // Body is the request body content. - Body string `json:"body,omitempty"` + Body []byte `json:"-"` // User contains the authenticated user information. Nil for auth:"none" endpoints. User *HTTPUser `json:"user,omitempty"` } @@ -36,7 +39,7 @@ type HTTPHandleResponse struct { // Headers contains the HTTP response headers to set. Headers map[string][]string `json:"headers,omitempty"` // Body is the response body content. - Body string `json:"body,omitempty"` + Body []byte `json:"-"` } // HTTPUser contains authenticated user information passed to the plugin. @@ -80,22 +83,44 @@ func _NdHttpHandleRequest() int32 { return NotImplementedCode } + // Parse input frame: [json_len:4B][JSON without []byte field][raw bytes] + raw := pdk.Input() + if len(raw) < 4 { + pdk.SetErrorString("malformed input frame") + return -1 + } + jsonLen := binary.BigEndian.Uint32(raw[:4]) + if uint32(len(raw)-4) < jsonLen { + pdk.SetErrorString("invalid json length in input frame") + return -1 + } var input HTTPHandleRequest - if err := pdk.InputJSON(&input); err != nil { + if err := json.Unmarshal(raw[4:4+jsonLen], &input); err != nil { pdk.SetError(err) return -1 } + input.Body = raw[4+jsonLen:] output, err := handleRequestImpl(input) if err != nil { - pdk.SetError(err) - return -1 + // Error frame: [0x01][UTF-8 error message] + errMsg := []byte(err.Error()) + errFrame := make([]byte, 1+len(errMsg)) + errFrame[0] = 0x01 + copy(errFrame[1:], errMsg) + pdk.Output(errFrame) + return 0 } - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } + // Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes] + jsonBytes, _ := json.Marshal(output) + rawBytes := output.Body + frame := make([]byte, 1+4+len(jsonBytes)+len(rawBytes)) + frame[0] = 0x00 + binary.BigEndian.PutUint32(frame[1:5], uint32(len(jsonBytes))) + copy(frame[5:5+len(jsonBytes)], jsonBytes) + copy(frame[5+len(jsonBytes):], rawBytes) + pdk.Output(frame) return 0 } diff --git a/plugins/pdk/go/httpendpoint/httpendpoint_stub.go b/plugins/pdk/go/httpendpoint/httpendpoint_stub.go index 5343b8e78..996dcc5ca 100644 --- a/plugins/pdk/go/httpendpoint/httpendpoint_stub.go +++ b/plugins/pdk/go/httpendpoint/httpendpoint_stub.go @@ -21,7 +21,7 @@ type HTTPHandleRequest struct { // Headers contains the HTTP request headers. Headers map[string][]string `json:"headers,omitempty"` // Body is the request body content. - Body string `json:"body,omitempty"` + Body []byte `json:"-"` // User contains the authenticated user information. Nil for auth:"none" endpoints. User *HTTPUser `json:"user,omitempty"` } @@ -33,7 +33,7 @@ type HTTPHandleResponse struct { // Headers contains the HTTP response headers to set. Headers map[string][]string `json:"headers,omitempty"` // Body is the response body content. - Body string `json:"body,omitempty"` + Body []byte `json:"-"` } // HTTPUser contains authenticated user information passed to the plugin. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs index 72cec1cb3..5840fdc7c 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs @@ -38,8 +38,8 @@ pub struct HTTPHandleRequest { #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub headers: std::collections::HashMap>, /// Body is the request body content. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub body: String, + #[serde(skip)] + pub body: Vec, /// User contains the authenticated user information. Nil for auth:"none" endpoints. #[serde(default, skip_serializing_if = "Option::is_none")] pub user: Option, @@ -55,8 +55,8 @@ pub struct HTTPHandleResponse { #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub headers: std::collections::HashMap>, /// Body is the response body content. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub body: String, + #[serde(skip)] + pub body: Vec, } /// HTTPUser contains authenticated user information passed to the plugin. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -112,11 +112,45 @@ macro_rules! register_httpendpoint { ($plugin_type:ty) => { #[extism_pdk::plugin_fn] pub fn nd_http_handle_request( - req: extism_pdk::Json<$crate::httpendpoint::HTTPHandleRequest> - ) -> extism_pdk::FnResult> { + _raw_input: extism_pdk::Raw> + ) -> extism_pdk::FnResult>> { let plugin = <$plugin_type>::default(); - let result = $crate::httpendpoint::HTTPEndpoint::handle_request(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) + // Parse input frame: [json_len:4B][JSON without []byte field][raw bytes] + let raw_bytes = _raw_input.0; + if raw_bytes.len() < 4 { + let mut err_frame = vec![0x01u8]; + err_frame.extend_from_slice(b"malformed input frame"); + return Ok(extism_pdk::Raw(err_frame)); + } + let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize; + if json_len > raw_bytes.len() - 4 { + let mut err_frame = vec![0x01u8]; + err_frame.extend_from_slice(b"invalid json length in input frame"); + return Ok(extism_pdk::Raw(err_frame)); + } + let mut req: $crate::httpendpoint::HTTPHandleRequest = serde_json::from_slice(&raw_bytes[4..4+json_len]) + .map_err(|e| extism_pdk::Error::msg(e.to_string()))?; + req.body = raw_bytes[4+json_len..].to_vec(); + match $crate::httpendpoint::HTTPEndpoint::handle_request(&plugin, req) { + Ok(output) => { + // Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes] + let json_bytes = serde_json::to_vec(&output) + .map_err(|e| extism_pdk::Error::msg(e.to_string()))?; + let raw_field = &output.body; + let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len()); + frame.push(0x00); + frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes()); + frame.extend_from_slice(&json_bytes); + frame.extend_from_slice(raw_field); + Ok(extism_pdk::Raw(frame)) + } + Err(e) => { + // Error frame: [0x01][UTF-8 error message] + let mut err_frame = vec![0x01u8]; + err_frame.extend_from_slice(e.message.as_bytes()); + Ok(extism_pdk::Raw(err_frame)) + } + } } }; } diff --git a/plugins/testdata/test-http-endpoint-native/main.go b/plugins/testdata/test-http-endpoint-native/main.go index 6a4cf6869..edb6c87d0 100644 --- a/plugins/testdata/test-http-endpoint-native/main.go +++ b/plugins/testdata/test-http-endpoint-native/main.go @@ -22,7 +22,7 @@ func (t *testNativeEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) ( Headers: map[string][]string{ "Content-Type": {"text/plain"}, }, - Body: "Hello from native auth plugin!", + Body: []byte("Hello from native auth plugin!"), }, nil case "/echo": @@ -31,7 +31,7 @@ func (t *testNativeEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) ( "method": req.Method, "path": req.Path, "query": req.Query, - "body": req.Body, + "body": string(req.Body), "hasUser": req.User != nil, "username": userName(req.User), }) @@ -40,13 +40,13 @@ func (t *testNativeEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) ( Headers: map[string][]string{ "Content-Type": {"application/json"}, }, - Body: string(data), + Body: data, }, nil default: return httpendpoint.HTTPHandleResponse{ Status: 404, - Body: "Not found: " + req.Path, + Body: []byte("Not found: " + req.Path), }, nil } } diff --git a/plugins/testdata/test-http-endpoint-public/main.go b/plugins/testdata/test-http-endpoint-public/main.go index 55689f6e3..4d82116b1 100644 --- a/plugins/testdata/test-http-endpoint-public/main.go +++ b/plugins/testdata/test-http-endpoint-public/main.go @@ -20,7 +20,7 @@ func (t *testPublicEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) ( Headers: map[string][]string{ "Content-Type": {"text/plain"}, }, - Body: "webhook received", + Body: []byte("webhook received"), }, nil case "/check-no-user": @@ -31,13 +31,13 @@ func (t *testPublicEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) ( } return httpendpoint.HTTPHandleResponse{ Status: 200, - Body: "hasUser=" + hasUser, + Body: []byte("hasUser=" + hasUser), }, nil default: return httpendpoint.HTTPHandleResponse{ Status: 404, - Body: "Not found: " + req.Path, + Body: []byte("Not found: " + req.Path), }, nil } } diff --git a/plugins/testdata/test-http-endpoint/main.go b/plugins/testdata/test-http-endpoint/main.go index 0b110c5cf..37a308cff 100644 --- a/plugins/testdata/test-http-endpoint/main.go +++ b/plugins/testdata/test-http-endpoint/main.go @@ -22,7 +22,7 @@ func (t *testEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpen Headers: map[string][]string{ "Content-Type": {"text/plain"}, }, - Body: "Hello from plugin!", + Body: []byte("Hello from plugin!"), }, nil case "/echo": @@ -31,7 +31,7 @@ func (t *testEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpen "method": req.Method, "path": req.Path, "query": req.Query, - "body": req.Body, + "body": string(req.Body), "hasUser": req.User != nil, "username": userName(req.User), }) @@ -40,19 +40,29 @@ func (t *testEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpen Headers: map[string][]string{ "Content-Type": {"application/json"}, }, - Body: string(data), + Body: data, + }, nil + + case "/binary": + // Return raw binary data (PNG header) + return httpendpoint.HTTPHandleResponse{ + Status: 200, + Headers: map[string][]string{ + "Content-Type": {"image/png"}, + }, + Body: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, }, nil case "/error": return httpendpoint.HTTPHandleResponse{ Status: 500, - Body: "Something went wrong", + Body: []byte("Something went wrong"), }, nil default: return httpendpoint.HTTPHandleResponse{ Status: 404, - Body: "Not found: " + req.Path, + Body: []byte("Not found: " + req.Path), }, nil } } From 425fe862baa5930f307a16e2c7e58cc8f1fcbb03 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Feb 2026 15:47:51 -0500 Subject: [PATCH 6/8] refactor(plugins): enhance user authorization handling and streamline form data processing Signed-off-by: Deluan --- plugins/host_subsonicapi.go | 7 ++--- plugins/http_endpoint.go | 28 ++++++++----------- plugins/manager_call.go | 51 +++++++++++++++++++--------------- server/subsonic/middlewares.go | 39 +++++++++++++------------- 4 files changed, 62 insertions(+), 63 deletions(-) diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go index ed44d139c..ea9523a61 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -86,11 +86,8 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string, 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. + // Use http.NewRequest (not WithContext) to avoid inheriting Chi RouteContext; + // auth context is set explicitly below 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) diff --git a/plugins/http_endpoint.go b/plugins/http_endpoint.go index d0fc96c49..68d1017bc 100644 --- a/plugins/http_endpoint.go +++ b/plugins/http_endpoint.go @@ -104,7 +104,8 @@ func (h *endpointHandler) serveWithNativeAuth(w http.ResponseWriter, r *http.Req func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *plugin) { ctx := r.Context() - // Check user authorization (skip for auth:"none") + // Check user authorization and extract user info (skip for auth:"none") + var httpUser *capabilities.HTTPUser if p.manifest.Permissions.Endpoints.Auth != EndpointsPermissionAuthNone { user, ok := request.UserFrom(ctx) if !ok { @@ -116,6 +117,12 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl http.Error(w, "Forbidden", http.StatusForbidden) return } + httpUser = &capabilities.HTTPUser{ + ID: user.ID, + Username: user.UserName, + Name: user.Name, + IsAdmin: user.IsAdmin, + } } // Read request body with size limit @@ -128,21 +135,10 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl // Build the plugin request // Normalize path: both /ext/plugin and /ext/plugin/ map to "" - relPath := "/" + chi.URLParam(r, "*") - if relPath == "/" || relPath == "" { - relPath = "" - } - - var httpUser *capabilities.HTTPUser - if p.manifest.Permissions.Endpoints.Auth != EndpointsPermissionAuthNone { - if user, ok := request.UserFrom(ctx); ok { - httpUser = &capabilities.HTTPUser{ - ID: user.ID, - Username: user.UserName, - Name: user.Name, - IsAdmin: user.IsAdmin, - } - } + rawPath := chi.URLParam(r, "*") + relPath := "" + if rawPath != "" { + relPath = "/" + rawPath } pluginReq := capabilities.HTTPHandleRequest{ diff --git a/plugins/manager_call.go b/plugins/manager_call.go index 12729767c..9ed8ee94c 100644 --- a/plugins/manager_call.go +++ b/plugins/manager_call.go @@ -61,39 +61,44 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcN startCall := time.Now() exit, output, err := p.CallWithContext(ctx, funcName, inputBytes) elapsed := time.Since(startCall) + + success := false + skipMetrics := false + defer func() { + if !skipMetrics { + plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, success, elapsed.Milliseconds()) + } + }() + if err != nil { // If context was cancelled, return that error instead of the plugin error if ctx.Err() != nil { + skipMetrics = true log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed) return result, ctx.Err() } - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err) return result, fmt.Errorf("plugin call failed: %w", err) } if exit != 0 { if exit == notImplementedCode { + skipMetrics = true log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start)) - // TODO Should we record metrics for not implemented calls? - //plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, true, elapsed.Milliseconds()) return result, fmt.Errorf("%w: %s", errNotImplemented, funcName) } - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) return result, fmt.Errorf("plugin call exited with code %d", exit) } if len(output) > 0 { - err = json.Unmarshal(output, &result) - if err != nil { + if err = json.Unmarshal(output, &result); err != nil { log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err) + return result, err } } - // Record metrics for successful calls (or JSON unmarshal failures) - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, err == nil, elapsed.Milliseconds()) - + success = true log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start)) - return result, err + return result, nil } // callPluginFunctionRaw calls a plugin function using binary framing for []byte fields. @@ -133,64 +138,64 @@ func callPluginFunctionRaw[I any, O any]( startCall := time.Now() exit, output, err := p.CallWithContext(ctx, funcName, frame) elapsed := time.Since(startCall) + + success := false + skipMetrics := false + defer func() { + if !skipMetrics { + plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, success, elapsed.Milliseconds()) + } + }() + if err != nil { if ctx.Err() != nil { + skipMetrics = true log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed) return result, ctx.Err() } - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err) return result, fmt.Errorf("plugin call failed: %w", err) } if exit != 0 { if exit == notImplementedCode { + skipMetrics = true log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start)) return result, fmt.Errorf("%w: %s", errNotImplemented, funcName) } - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) return result, fmt.Errorf("plugin call exited with code %d", exit) } // Parse output frame if len(output) < 1 { - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) return result, fmt.Errorf("empty response from plugin") } statusByte := output[0] if statusByte == 0x01 { - // Error frame: [0x01][UTF-8 error message] - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) - errMsg := string(output[1:]) - return result, fmt.Errorf("plugin error: %s", errMsg) + return result, fmt.Errorf("plugin error: %s", string(output[1:])) } - if statusByte != 0x00 { - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) return result, fmt.Errorf("unknown response status byte: 0x%02x", statusByte) } // Success frame: [0x00][json_len:4B][JSON][raw bytes] if len(output) < 5 { - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) return result, fmt.Errorf("malformed success response from plugin") } jsonLen := binary.BigEndian.Uint32(output[1:5]) if uint32(len(output)-5) < jsonLen { - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) return result, fmt.Errorf("invalid json length in response frame: %d exceeds available %d bytes", jsonLen, len(output)-5) } jsonData := output[5 : 5+jsonLen] rawData := output[5+jsonLen:] if err := json.Unmarshal(jsonData, &result); err != nil { - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) return result, fmt.Errorf("failed to unmarshal response: %w", err) } setRawOutput(&result, rawData) - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, true, elapsed.Milliseconds()) + success = true log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start)) return result, nil } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 97fc29fe5..cf9896f10 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -28,20 +28,29 @@ import ( "github.com/navidrome/navidrome/utils/req" ) +// mergeFormIntoQuery parses form data (both URL query params and POST body) +// and writes all values back into r.URL.RawQuery. This is needed because +// some Subsonic clients send parameters as form fields instead of query params. +// This support the OpenSubsonic `formPost` extension +func mergeFormIntoQuery(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return err + } + var parts []string + for key, values := range r.Form { + for _, v := range values { + parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v)) + } + } + r.URL.RawQuery = strings.Join(parts, "&") + return nil +} + func postFormToQueryParams(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err != nil { + if err := mergeFormIntoQuery(r); err != nil { sendError(w, r, newError(responses.ErrorGeneric, err.Error())) } - var parts []string - for key, values := range r.Form { - for _, v := range values { - parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v)) - } - } - r.URL.RawQuery = strings.Join(parts, "&") - next.ServeHTTP(w, r) }) } @@ -169,17 +178,9 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { func ValidateAuth(ds model.DataStore, r *http.Request) (*model.User, error) { // Parse form data into query params (same as postFormToQueryParams middleware, // which is not in the call chain when ValidateAuth is used directly) - if err := r.ParseForm(); err != nil { + if err := mergeFormIntoQuery(r); err != nil { return nil, fmt.Errorf("parsing form: %w", err) } - var parts []string - for key, values := range r.Form { - for _, v := range values { - parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v)) - } - } - r.URL.RawQuery = strings.Join(parts, "&") - return authenticateRequest(ds, r) } From fc113d1dc647ab7b08564824d9b826f63f58d7bf Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Feb 2026 15:55:43 -0500 Subject: [PATCH 7/8] fix(plugins): guard against integer overflow in callPluginFunctionRaw frame allocation Add overflow check before allocating the input frame buffer to prevent potential integer wraparound on 32-bit platforms (flagged by github-advanced-security). --- plugins/manager_call.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/manager_call.go b/plugins/manager_call.go index 9ed8ee94c..fbd46eb52 100644 --- a/plugins/manager_call.go +++ b/plugins/manager_call.go @@ -130,7 +130,11 @@ func callPluginFunctionRaw[I any, O any]( if err != nil { return result, fmt.Errorf("failed to marshal input: %w", err) } - frame := make([]byte, 4+len(jsonBytes)+len(rawInputBytes)) + totalSize := 4 + len(jsonBytes) + len(rawInputBytes) + if totalSize < len(jsonBytes) || totalSize < len(rawInputBytes) { + return result, fmt.Errorf("input frame too large") + } + frame := make([]byte, totalSize) binary.BigEndian.PutUint32(frame[:4], uint32(len(jsonBytes))) copy(frame[4:4+len(jsonBytes)], jsonBytes) copy(frame[4+len(jsonBytes):], rawInputBytes) From 6c260db60cb610c7eb5ad7c94c79cee558820785 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Feb 2026 16:02:54 -0500 Subject: [PATCH 8/8] fix(plugins): use size cap instead of wraparound check for CodeQL overflow warning Check individual slice sizes against a 128 MiB cap before the addition, so CodeQL can statically verify the sum cannot overflow. --- plugins/manager_call.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/manager_call.go b/plugins/manager_call.go index fbd46eb52..6989b8c67 100644 --- a/plugins/manager_call.go +++ b/plugins/manager_call.go @@ -130,11 +130,11 @@ func callPluginFunctionRaw[I any, O any]( if err != nil { return result, fmt.Errorf("failed to marshal input: %w", err) } - totalSize := 4 + len(jsonBytes) + len(rawInputBytes) - if totalSize < len(jsonBytes) || totalSize < len(rawInputBytes) { + const maxFrameSize = 2 << 20 // 2 MiB + if len(jsonBytes) > maxFrameSize || len(rawInputBytes) > maxFrameSize { return result, fmt.Errorf("input frame too large") } - frame := make([]byte, totalSize) + frame := make([]byte, 4+len(jsonBytes)+len(rawInputBytes)) binary.BigEndian.PutUint32(frame[:4], uint32(len(jsonBytes))) copy(frame[4:4+len(jsonBytes)], jsonBytes) copy(frame[4+len(jsonBytes):], rawInputBytes)