diff --git a/db/migrations/20251227192712_create_plugin_table.sql b/db/migrations/20251227192712_create_plugin_table.sql new file mode 100644 index 000000000..18a6bfa35 --- /dev/null +++ b/db/migrations/20251227192712_create_plugin_table.sql @@ -0,0 +1,15 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS plugin ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL, + manifest TEXT NOT NULL, + config TEXT, + enabled INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + sha256 TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); + +-- +goose Down +DROP TABLE IF EXISTS plugin; diff --git a/model/datastore.go b/model/datastore.go index 601fab2d3..a187c4953 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -39,6 +39,7 @@ type DataStore interface { UserProps(ctx context.Context) UserPropsRepository ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository Scrobble(ctx context.Context) ScrobbleRepository + Plugin(ctx context.Context) PluginRepository Resource(ctx context.Context, model interface{}) ResourceRepository diff --git a/model/plugin.go b/model/plugin.go new file mode 100644 index 000000000..44ade6109 --- /dev/null +++ b/model/plugin.go @@ -0,0 +1,26 @@ +package model + +import "time" + +type Plugin struct { + ID string `structs:"id" json:"id"` + Path string `structs:"path" json:"path"` + Manifest string `structs:"manifest" json:"manifest"` + Config string `structs:"config" json:"config,omitempty"` + Enabled bool `structs:"enabled" json:"enabled"` + LastError string `structs:"last_error" json:"lastError,omitempty"` + SHA256 string `structs:"sha256" json:"sha256"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` +} + +type Plugins []Plugin + +type PluginRepository interface { + ResourceRepository + CountAll(options ...QueryOptions) (int64, error) + Delete(id string) error + Get(id string) (*Plugin, error) + GetAll(options ...QueryOptions) (Plugins, error) + Put(p *Plugin) error +} diff --git a/persistence/persistence.go b/persistence/persistence.go index 9599de179..afc7537e6 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -93,6 +93,10 @@ func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository { return NewScrobbleRepository(ctx, s.getDBXBuilder()) } +func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository { + return NewPluginRepository(ctx, s.getDBXBuilder()) +} + func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository { switch m.(type) { case model.User: @@ -117,6 +121,8 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe return s.Share(ctx).(model.ResourceRepository) case model.Tag: return s.Tag(ctx).(model.ResourceRepository) + case model.Plugin: + return s.Plugin(ctx).(model.ResourceRepository) } log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name()) return nil diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go new file mode 100644 index 000000000..534f9efb5 --- /dev/null +++ b/persistence/plugin_repository.go @@ -0,0 +1,146 @@ +package persistence + +import ( + "context" + "errors" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +type pluginRepository struct { + sqlRepository +} + +func NewPluginRepository(ctx context.Context, db dbx.Builder) model.PluginRepository { + r := &pluginRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.Plugin{}, map[string]filterFunc{ + "id": idFilter("plugin"), + "enabled": booleanFilter, + }) + return r +} + +func (r *pluginRepository) isPermitted() bool { + user := loggedUser(r.ctx) + return user.IsAdmin +} + +func (r *pluginRepository) CountAll(options ...model.QueryOptions) (int64, error) { + if !r.isPermitted() { + return 0, rest.ErrPermissionDenied + } + sql := r.newSelect() + return r.count(sql, options...) +} + +func (r *pluginRepository) Delete(id string) error { + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + return r.delete(Eq{"id": id}) +} + +func (r *pluginRepository) Get(id string) (*model.Plugin, error) { + if !r.isPermitted() { + return nil, rest.ErrPermissionDenied + } + sel := r.newSelect().Where(Eq{"id": id}).Columns("*") + res := model.Plugin{} + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *pluginRepository) GetAll(options ...model.QueryOptions) (model.Plugins, error) { + if !r.isPermitted() { + return nil, rest.ErrPermissionDenied + } + sel := r.newSelect(options...).Columns("*") + res := model.Plugins{} + err := r.queryAll(sel, &res) + return res, err +} + +func (r *pluginRepository) Put(plugin *model.Plugin) error { + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + + plugin.UpdatedAt = time.Now() + + if plugin.ID == "" { + return errors.New("plugin ID cannot be empty") + } + + // Try update first + values, _ := toSQLArgs(*plugin) + update := Update(r.tableName).Where(Eq{"id": plugin.ID}).SetMap(values) + count, err := r.executeSQL(update) + if err != nil { + return err + } + if count > 0 { + return nil + } + + // Insert if not exists + plugin.CreatedAt = time.Now() + values, _ = toSQLArgs(*plugin) + insert := Insert(r.tableName).SetMap(values) + _, err = r.executeSQL(insert) + return err +} + +func (r *pluginRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *pluginRepository) EntityName() string { + return "plugin" +} + +func (r *pluginRepository) NewInstance() interface{} { + return &model.Plugin{} +} + +func (r *pluginRepository) Read(id string) (interface{}, error) { + return r.Get(id) +} + +func (r *pluginRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *pluginRepository) Save(entity interface{}) (string, error) { + p := entity.(*model.Plugin) + if !r.isPermitted() { + return "", rest.ErrPermissionDenied + } + err := r.Put(p) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + return p.ID, err +} + +func (r *pluginRepository) Update(id string, entity interface{}, cols ...string) error { + p := entity.(*model.Plugin) + p.ID = id + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + err := r.Put(p) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err +} + +var _ model.PluginRepository = (*pluginRepository)(nil) +var _ rest.Repository = (*pluginRepository)(nil) +var _ rest.Persistable = (*pluginRepository)(nil) diff --git a/persistence/plugin_repository_test.go b/persistence/plugin_repository_test.go new file mode 100644 index 000000000..ee158a31c --- /dev/null +++ b/persistence/plugin_repository_test.go @@ -0,0 +1,227 @@ +package persistence + +import ( + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PluginRepository", func() { + var repo model.PluginRepository + + Describe("Admin User", func() { + BeforeEach(func() { + ctx := GinkgoT().Context() + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPluginRepository(ctx, GetDBXBuilder()) + + // Clean up any existing plugins + all, _ := repo.GetAll() + for _, p := range all { + _ = repo.Delete(p.ID) + } + }) + + AfterEach(func() { + // Clean up after tests + all, _ := repo.GetAll() + for _, p := range all { + _ = repo.Delete(p.ID) + } + }) + + Describe("CountAll", func() { + It("returns 0 when no plugins exist", func() { + Expect(repo.CountAll()).To(Equal(int64(0))) + }) + + It("returns the number of plugins in the DB", func() { + _ = repo.Put(&model.Plugin{ID: "test-plugin-1", Path: "/plugins/test1.wasm", Manifest: "{}", SHA256: "abc123"}) + _ = repo.Put(&model.Plugin{ID: "test-plugin-2", Path: "/plugins/test2.wasm", Manifest: "{}", SHA256: "def456"}) + + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + }) + + Describe("Delete", func() { + It("deletes existing item", func() { + plugin := &model.Plugin{ID: "to-delete", Path: "/plugins/delete.wasm", Manifest: "{}", SHA256: "hash"} + _ = repo.Put(plugin) + + err := repo.Delete(plugin.ID) + Expect(err).To(BeNil()) + + _, err = repo.Get(plugin.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("Get", func() { + It("returns an existing item", func() { + plugin := &model.Plugin{ID: "test-get", Path: "/plugins/test.wasm", Manifest: `{"name":"test"}`, SHA256: "hash123"} + _ = repo.Put(plugin) + + res, err := repo.Get(plugin.ID) + Expect(err).To(BeNil()) + Expect(res.ID).To(Equal(plugin.ID)) + Expect(res.Path).To(Equal(plugin.Path)) + Expect(res.Manifest).To(Equal(plugin.Manifest)) + }) + + It("errors when missing", func() { + _, err := repo.Get("notanid") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all items from the DB", func() { + _ = repo.Put(&model.Plugin{ID: "plugin-a", Path: "/plugins/a.wasm", Manifest: "{}", SHA256: "hash1"}) + _ = repo.Put(&model.Plugin{ID: "plugin-b", Path: "/plugins/b.wasm", Manifest: "{}", SHA256: "hash2"}) + + all, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(all).To(HaveLen(2)) + }) + + It("supports pagination", func() { + _ = repo.Put(&model.Plugin{ID: "plugin-1", Path: "/plugins/1.wasm", Manifest: "{}", SHA256: "h1"}) + _ = repo.Put(&model.Plugin{ID: "plugin-2", Path: "/plugins/2.wasm", Manifest: "{}", SHA256: "h2"}) + _ = repo.Put(&model.Plugin{ID: "plugin-3", Path: "/plugins/3.wasm", Manifest: "{}", SHA256: "h3"}) + + page1, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 0, Sort: "id"}) + Expect(err).To(BeNil()) + Expect(page1).To(HaveLen(2)) + + page2, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 2, Sort: "id"}) + Expect(err).To(BeNil()) + Expect(page2).To(HaveLen(1)) + }) + }) + + Describe("Put", func() { + It("successfully creates a new plugin", func() { + plugin := &model.Plugin{ + ID: "new-plugin", + Path: "/plugins/new.wasm", + Manifest: `{"name":"new","version":"1.0"}`, + Config: `{"setting":"value"}`, + SHA256: "sha256hash", + Enabled: false, + } + + err := repo.Put(plugin) + Expect(err).To(BeNil()) + + saved, err := repo.Get(plugin.ID) + Expect(err).To(BeNil()) + Expect(saved.Path).To(Equal(plugin.Path)) + Expect(saved.Manifest).To(Equal(plugin.Manifest)) + Expect(saved.Config).To(Equal(plugin.Config)) + Expect(saved.Enabled).To(BeFalse()) + Expect(saved.CreatedAt).NotTo(BeZero()) + Expect(saved.UpdatedAt).NotTo(BeZero()) + }) + + It("successfully updates an existing plugin", func() { + plugin := &model.Plugin{ + ID: "update-plugin", + Path: "/plugins/update.wasm", + Manifest: `{"name":"test"}`, + SHA256: "original", + Enabled: false, + } + _ = repo.Put(plugin) + + plugin.Enabled = true + plugin.Config = `{"new":"config"}` + plugin.SHA256 = "updated" + err := repo.Put(plugin) + Expect(err).To(BeNil()) + + saved, err := repo.Get(plugin.ID) + Expect(err).To(BeNil()) + Expect(saved.Enabled).To(BeTrue()) + Expect(saved.Config).To(Equal(`{"new":"config"}`)) + Expect(saved.SHA256).To(Equal("updated")) + }) + + It("stores and retrieves last_error", func() { + plugin := &model.Plugin{ + ID: "error-plugin", + Path: "/plugins/error.wasm", + Manifest: "{}", + SHA256: "hash", + LastError: "failed to load: missing export", + } + err := repo.Put(plugin) + Expect(err).To(BeNil()) + + saved, err := repo.Get(plugin.ID) + Expect(err).To(BeNil()) + Expect(saved.LastError).To(Equal("failed to load: missing export")) + }) + + It("fails when ID is empty", func() { + plugin := &model.Plugin{ + Path: "/plugins/noid.wasm", + Manifest: "{}", + SHA256: "hash", + } + err := repo.Put(plugin) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("ID cannot be empty")) + }) + }) + }) + + Describe("Regular User", func() { + BeforeEach(func() { + ctx := GinkgoT().Context() + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false}) + repo = NewPluginRepository(ctx, GetDBXBuilder()) + }) + + Describe("CountAll", func() { + It("fails to count items", func() { + _, err := repo.CountAll() + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + }) + + Describe("Delete", func() { + It("fails to delete items", func() { + err := repo.Delete("any-id") + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + }) + + Describe("Get", func() { + It("fails to get items", func() { + _, err := repo.Get("any-id") + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + }) + + Describe("GetAll", func() { + It("fails to get all items", func() { + _, err := repo.GetAll() + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + }) + + Describe("Put", func() { + It("fails to create/update item", func() { + err := repo.Put(&model.Plugin{ + ID: "user-create", + Path: "/plugins/create.wasm", + Manifest: "{}", + SHA256: "hash", + }) + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + }) + }) +}) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 8ac7b58ad..6da25be56 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -27,6 +27,7 @@ type MockDataStore struct { MockedScrobbleBuffer model.ScrobbleBufferRepository MockedScrobble model.ScrobbleRepository MockedRadio model.RadioRepository + MockedPlugin model.PluginRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -237,6 +238,17 @@ func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { return db.MockedRadio } +func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository { + if db.MockedPlugin == nil { + if db.RealDS != nil { + db.MockedPlugin = db.RealDS.Plugin(ctx) + } else { + db.MockedPlugin = struct{ model.PluginRepository }{} + } + } + return db.MockedPlugin +} + func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } @@ -269,6 +281,8 @@ func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepo return db.Transcoding(ctx).(model.ResourceRepository) case model.Player, *model.Player: return db.Player(ctx).(model.ResourceRepository) + case model.Plugin, *model.Plugin: + return db.Plugin(ctx).(model.ResourceRepository) default: return struct{ model.ResourceRepository }{} }