mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(plugins): implement HTTP endpoint capability for plugins
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
f00af7f983
commit
9f7b6870ac
10
cmd/root.go
10
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
51
plugins/capabilities/http_endpoint.go
Normal file
51
plugins/capabilities/http_endpoint.go
Normal file
@ -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"`
|
||||
}
|
||||
14
plugins/capability_http_endpoint.go
Normal file
14
plugins/capability_http_endpoint.go
Normal file
@ -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,
|
||||
)
|
||||
}
|
||||
184
plugins/http_endpoint.go
Normal file
184
plugins/http_endpoint.go
Normal file
@ -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)
|
||||
}
|
||||
374
plugins/http_endpoint_test.go
Normal file
374
plugins/http_endpoint_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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"`
|
||||
|
||||
|
||||
100
plugins/pdk/go/httpendpoint/httpendpoint.go
Normal file
100
plugins/pdk/go/httpendpoint/httpendpoint.go
Normal file
@ -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
|
||||
}
|
||||
64
plugins/pdk/go/httpendpoint/httpendpoint_stub.go
Normal file
64
plugins/pdk/go/httpendpoint/httpendpoint_stub.go
Normal file
@ -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) {}
|
||||
121
plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs
Normal file
121
plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs
Normal file
@ -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<String, Vec<String>>,
|
||||
/// 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<HTTPUser>,
|
||||
}
|
||||
/// 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<String, Vec<String>>,
|
||||
/// 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<String>) -> 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<HTTPHandleResponse, Error>;
|
||||
}
|
||||
|
||||
/// 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<extism_pdk::Json<$crate::httpendpoint::HTTPHandleResponse>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
let result = $crate::httpendpoint::HTTPEndpoint::handle_request(&plugin, req.into_inner())?;
|
||||
Ok(extism_pdk::Json(result))
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
16
plugins/testdata/test-http-endpoint-public/go.mod
vendored
Normal file
16
plugins/testdata/test-http-endpoint-public/go.mod
vendored
Normal file
@ -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
|
||||
14
plugins/testdata/test-http-endpoint-public/go.sum
vendored
Normal file
14
plugins/testdata/test-http-endpoint-public/go.sum
vendored
Normal file
@ -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=
|
||||
45
plugins/testdata/test-http-endpoint-public/main.go
vendored
Normal file
45
plugins/testdata/test-http-endpoint-public/main.go
vendored
Normal file
@ -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() {}
|
||||
13
plugins/testdata/test-http-endpoint-public/manifest.json
vendored
Normal file
13
plugins/testdata/test-http-endpoint-public/manifest.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
plugins/testdata/test-http-endpoint/go.mod
vendored
Normal file
16
plugins/testdata/test-http-endpoint/go.mod
vendored
Normal file
@ -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
|
||||
14
plugins/testdata/test-http-endpoint/go.sum
vendored
Normal file
14
plugins/testdata/test-http-endpoint/go.sum
vendored
Normal file
@ -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=
|
||||
67
plugins/testdata/test-http-endpoint/main.go
vendored
Normal file
67
plugins/testdata/test-http-endpoint/main.go
vendored
Normal file
@ -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() {}
|
||||
16
plugins/testdata/test-http-endpoint/manifest.json
vendored
Normal file
16
plugins/testdata/test-http-endpoint/manifest.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user