From 9f7b6870ac1d893e07f364adac35f9fd193474a7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Feb 2026 11:55:10 -0500 Subject: [PATCH] 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