feat(plugins UI): add plugin management routes and middleware

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-27 15:00:32 -05:00
parent 52c3985508
commit 9c626183d0
5 changed files with 586 additions and 1 deletions

View File

@ -72,6 +72,7 @@ func (api *Router) routes() http.Handler {
api.addInspectRoute(r)
api.addConfigRoute(r)
api.addUserLibraryRoute(r)
api.addPluginRoute(r)
api.RX(r, "/library", api.libs.NewRepository, true)
})
})

112
server/nativeapi/plugin.go Normal file
View File

@ -0,0 +1,112 @@
package nativeapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
)
func (api *Router) addPluginRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
return api.ds.Plugin(ctx)
}
r.Route("/plugin", func(r chi.Router) {
r.Use(pluginsEnabledMiddleware)
r.Get("/", rest.GetAll(constructor))
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", rest.Get(constructor))
r.Put("/", api.updatePlugin)
})
})
}
// Middleware to check if plugins feature is enabled
func pluginsEnabledMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !conf.Server.Plugins.Enabled {
http.Error(w, "Not found", http.StatusNotFound)
return
}
next.ServeHTTP(w, r)
})
}
// PluginUpdateRequest represents the fields that can be updated via the API
type PluginUpdateRequest struct {
Enabled *bool `json:"enabled,omitempty"`
Config *string `json:"config,omitempty"`
}
func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
repo := api.ds.Plugin(r.Context())
// Get existing plugin
plugin, err := repo.Get(id)
if err != nil {
if errors.Is(err, rest.ErrPermissionDenied) {
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
return
}
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "Plugin not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error getting plugin", "id", id, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Parse update request
var req PluginUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Error(r.Context(), "Error decoding request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Apply updates
if req.Enabled != nil {
plugin.Enabled = *req.Enabled
}
if req.Config != nil {
// Validate JSON if not empty
if *req.Config != "" && !isValidJSON(*req.Config) {
http.Error(w, "Invalid JSON in config field", http.StatusBadRequest)
return
}
plugin.Config = *req.Config
}
// Save
if err := repo.Put(plugin); err != nil {
if errors.Is(err, rest.ErrPermissionDenied) {
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
return
}
log.Error(r.Context(), "Error updating plugin", "id", id, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(plugin); err != nil {
log.Error(r.Context(), "Error encoding plugin response", err)
}
}
// isValidJSON checks if a string is valid JSON
func isValidJSON(s string) bool {
var js json.RawMessage
return json.Unmarshal([]byte(s), &js) == nil
}

View File

@ -0,0 +1,298 @@
package nativeapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Plugin API", func() {
var ds *tests.MockDataStore
var router http.Handler
var adminUser, regularUser model.User
var testPlugin1, testPlugin2 model.Plugin
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
adminUser = model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "adminpass",
}
regularUser = model.User{
ID: "user-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "userpass",
}
// Create test plugins
testPlugin1 = model.Plugin{
ID: "test-plugin-1",
Path: "/plugins/test1.wasm",
Manifest: `{"name":"Test Plugin 1","version":"1.0.0"}`,
SHA256: "abc123",
Enabled: false,
}
testPlugin2 = model.Plugin{
ID: "test-plugin-2",
Path: "/plugins/test2.wasm",
Manifest: `{"name":"Test Plugin 2","version":"2.0.0"}`,
Config: `{"setting":"value"}`,
SHA256: "def456",
Enabled: true,
}
// Store users in mock datastore
Expect(ds.User(GinkgoT().Context()).Put(&adminUser)).To(Succeed())
Expect(ds.User(GinkgoT().Context()).Put(&regularUser)).To(Succeed())
})
Context("when plugins are disabled", func() {
BeforeEach(func() {
conf.Server.Plugins.Enabled = false
})
It("returns 404 for all plugin endpoints", func() {
adminToken, err := auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest("GET", "/plugin", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Context("when plugins are enabled", func() {
Describe("as admin user", func() {
var adminToken string
BeforeEach(func() {
var err error
adminToken, err = auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
// Store test plugins as admin
ctx := GinkgoT().Context()
adminCtx := request.WithUser(ctx, adminUser)
Expect(ds.Plugin(adminCtx).Put(&testPlugin1)).To(Succeed())
Expect(ds.Plugin(adminCtx).Put(&testPlugin2)).To(Succeed())
})
Describe("GET /api/plugin", func() {
It("returns all plugins", func() {
req := httptest.NewRequest("GET", "/plugin", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var plugins []model.Plugin
err := json.Unmarshal(w.Body.Bytes(), &plugins)
Expect(err).ToNot(HaveOccurred())
Expect(plugins).To(HaveLen(2))
})
})
Describe("GET /api/plugin/{id}", func() {
It("returns a specific plugin", func() {
req := httptest.NewRequest("GET", "/plugin/test-plugin-1", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var plugin model.Plugin
err := json.Unmarshal(w.Body.Bytes(), &plugin)
Expect(err).ToNot(HaveOccurred())
Expect(plugin.ID).To(Equal("test-plugin-1"))
Expect(plugin.Path).To(Equal("/plugins/test1.wasm"))
})
It("returns 404 for non-existent plugin", func() {
req := httptest.NewRequest("GET", "/plugin/non-existent", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("PUT /api/plugin/{id}", func() {
It("updates plugin enabled state", func() {
body := bytes.NewBufferString(`{"enabled":true}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var plugin model.Plugin
err := json.Unmarshal(w.Body.Bytes(), &plugin)
Expect(err).ToNot(HaveOccurred())
Expect(plugin.Enabled).To(BeTrue())
})
It("updates plugin config with valid JSON", func() {
body := bytes.NewBufferString(`{"config":"{\"key\":\"value\"}"}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var plugin model.Plugin
err := json.Unmarshal(w.Body.Bytes(), &plugin)
Expect(err).ToNot(HaveOccurred())
Expect(plugin.Config).To(Equal(`{"key":"value"}`))
})
It("rejects invalid JSON in config field", func() {
body := bytes.NewBufferString(`{"config":"not valid json"}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("Invalid JSON"))
})
It("allows empty config", func() {
body := bytes.NewBufferString(`{"config":""}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var plugin model.Plugin
err := json.Unmarshal(w.Body.Bytes(), &plugin)
Expect(err).ToNot(HaveOccurred())
Expect(plugin.Config).To(Equal(""))
})
It("returns 404 for non-existent plugin", func() {
body := bytes.NewBufferString(`{"enabled":true}`)
req := httptest.NewRequest("PUT", "/plugin/non-existent", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 400 for invalid request body", func() {
body := bytes.NewBufferString(`not json`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
})
})
Describe("as regular user", func() {
var userToken string
BeforeEach(func() {
var err error
userToken, err = auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
})
It("denies access to GET /api/plugin", func() {
req := httptest.NewRequest("GET", "/plugin", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
})
It("denies access to GET /api/plugin/{id}", func() {
req := httptest.NewRequest("GET", "/plugin/test-plugin-1", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
})
It("denies access to PUT /api/plugin/{id}", func() {
body := bytes.NewBufferString(`{"enabled":true}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
})
})
Describe("without authentication", func() {
It("denies access to plugin endpoints", func() {
req := httptest.NewRequest("GET", "/plugin", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
})

View File

@ -243,7 +243,7 @@ func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository {
if db.RealDS != nil {
db.MockedPlugin = db.RealDS.Plugin(ctx)
} else {
db.MockedPlugin = struct{ model.PluginRepository }{}
db.MockedPlugin = CreateMockPluginRepo()
}
}
return db.MockedPlugin

174
tests/mock_plugin_repo.go Normal file
View File

@ -0,0 +1,174 @@
package tests
import (
"errors"
"time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
)
func CreateMockPluginRepo() *MockPluginRepo {
return &MockPluginRepo{
Data: make(map[string]*model.Plugin),
IsAdmin: true, // Default to admin access
Permitted: true,
}
}
type MockPluginRepo struct {
Data map[string]*model.Plugin
All model.Plugins
Err bool
Options model.QueryOptions
IsAdmin bool
Permitted bool
}
func (m *MockPluginRepo) SetError(err bool) {
m.Err = err
}
func (m *MockPluginRepo) SetData(plugins model.Plugins) {
m.Data = make(map[string]*model.Plugin, len(plugins))
m.All = plugins
for i, p := range m.All {
m.Data[p.ID] = &m.All[i]
}
}
func (m *MockPluginRepo) SetPermitted(permitted bool) {
m.Permitted = permitted
}
func (m *MockPluginRepo) Get(id string) (*model.Plugin, error) {
if !m.Permitted {
return nil, rest.ErrPermissionDenied
}
if m.Err {
return nil, errors.New("unexpected error")
}
if d, ok := m.Data[id]; ok {
return d, nil
}
return nil, model.ErrNotFound
}
func (m *MockPluginRepo) Read(id string) (interface{}, error) {
p, err := m.Get(id)
if errors.Is(err, model.ErrNotFound) {
return nil, rest.ErrNotFound
}
return p, err
}
func (m *MockPluginRepo) Put(p *model.Plugin) error {
if !m.Permitted {
return rest.ErrPermissionDenied
}
if m.Err {
return errors.New("unexpected error")
}
if p.ID == "" {
return errors.New("plugin ID cannot be empty")
}
now := time.Now()
if existing, ok := m.Data[p.ID]; ok {
p.CreatedAt = existing.CreatedAt
} else {
p.CreatedAt = now
}
p.UpdatedAt = now
m.Data[p.ID] = p
// Update All slice
found := false
for i, existing := range m.All {
if existing.ID == p.ID {
m.All[i] = *p
found = true
break
}
}
if !found {
m.All = append(m.All, *p)
}
return nil
}
func (m *MockPluginRepo) Delete(id string) error {
if !m.Permitted {
return rest.ErrPermissionDenied
}
if m.Err {
return errors.New("unexpected error")
}
delete(m.Data, id)
// Update All slice
for i, p := range m.All {
if p.ID == id {
m.All = append(m.All[:i], m.All[i+1:]...)
break
}
}
return nil
}
func (m *MockPluginRepo) GetAll(qo ...model.QueryOptions) (model.Plugins, error) {
if len(qo) > 0 {
m.Options = qo[0]
}
if !m.Permitted {
return nil, rest.ErrPermissionDenied
}
if m.Err {
return nil, errors.New("unexpected error")
}
return m.All, nil
}
func (m *MockPluginRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
if len(qo) > 0 {
m.Options = qo[0]
}
if !m.Permitted {
return 0, rest.ErrPermissionDenied
}
if m.Err {
return 0, errors.New("unexpected error")
}
return int64(len(m.All)), nil
}
// rest.Repository interface methods
func (m *MockPluginRepo) Count(options ...rest.QueryOptions) (int64, error) {
if !m.Permitted {
return 0, rest.ErrPermissionDenied
}
return int64(len(m.All)), nil
}
func (m *MockPluginRepo) EntityName() string {
return "plugin"
}
func (m *MockPluginRepo) NewInstance() interface{} {
return &model.Plugin{}
}
func (m *MockPluginRepo) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return m.GetAll()
}
func (m *MockPluginRepo) Save(entity interface{}) (string, error) {
p := entity.(*model.Plugin)
err := m.Put(p)
return p.ID, err
}
func (m *MockPluginRepo) Update(id string, entity interface{}, cols ...string) error {
p := entity.(*model.Plugin)
p.ID = id
return m.Put(p)
}
var _ model.PluginRepository = (*MockPluginRepo)(nil)