diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 969650e0a..1cd340694 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -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) }) }) diff --git a/server/nativeapi/plugin.go b/server/nativeapi/plugin.go new file mode 100644 index 000000000..1636ce44c --- /dev/null +++ b/server/nativeapi/plugin.go @@ -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 +} diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go new file mode 100644 index 000000000..69aaebc39 --- /dev/null +++ b/server/nativeapi/plugin_test.go @@ -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(®ularUser)).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(®ularUser) + 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)) + }) + }) + }) +}) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 6da25be56..6b696ee72 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -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 diff --git a/tests/mock_plugin_repo.go b/tests/mock_plugin_repo.go new file mode 100644 index 000000000..213d83001 --- /dev/null +++ b/tests/mock_plugin_repo.go @@ -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)