diff --git a/db/migrations/20251227192712_create_plugin_table.sql b/db/migrations/20251227192712_create_plugin_table.sql index a25287fc8..bcc83be0b 100644 --- a/db/migrations/20251227192712_create_plugin_table.sql +++ b/db/migrations/20251227192712_create_plugin_table.sql @@ -2,11 +2,13 @@ CREATE TABLE IF NOT EXISTS plugin ( id TEXT PRIMARY KEY, path TEXT NOT NULL, - manifest TEXT NOT NULL, - config TEXT, - users TEXT, - all_users INTEGER NOT NULL DEFAULT 0, - enabled INTEGER NOT NULL DEFAULT 0, + manifest JSONB NOT NULL, + config JSONB, + users JSONB, + all_users BOOL NOT NULL DEFAULT false, + libraries JSONB, + all_libraries BOOL NOT NULL DEFAULT false, + enabled BOOL NOT NULL DEFAULT false, last_error TEXT, sha256 TEXT NOT NULL, created_at DATETIME NOT NULL, diff --git a/model/plugin.go b/model/plugin.go index 063fb043f..d23103995 100644 --- a/model/plugin.go +++ b/model/plugin.go @@ -3,17 +3,19 @@ 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"` - Users string `structs:"users" json:"users,omitempty"` - AllUsers bool `structs:"all_users" json:"allUsers,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"` + 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"` + Users string `structs:"users" json:"users,omitempty"` + AllUsers bool `structs:"all_users" json:"allUsers,omitempty"` + Libraries string `structs:"libraries" json:"libraries,omitempty"` + AllLibraries bool `structs:"all_libraries" json:"allLibraries,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 diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go index 53966a2e0..4a98f148b 100644 --- a/persistence/plugin_repository.go +++ b/persistence/plugin_repository.go @@ -79,30 +79,34 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error { // Upsert using INSERT ... ON CONFLICT for atomic operation _, err := r.db.NewQuery(` - INSERT INTO plugin (id, path, manifest, config, users, all_users, enabled, last_error, sha256, created_at, updated_at) - VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at}) + INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, enabled, last_error, sha256, created_at, updated_at) + VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at}) ON CONFLICT(id) DO UPDATE SET path = excluded.path, manifest = excluded.manifest, config = excluded.config, users = excluded.users, all_users = excluded.all_users, + libraries = excluded.libraries, + all_libraries = excluded.all_libraries, enabled = excluded.enabled, last_error = excluded.last_error, sha256 = excluded.sha256, updated_at = excluded.updated_at `).Bind(dbx.Params{ - "id": plugin.ID, - "path": plugin.Path, - "manifest": plugin.Manifest, - "config": plugin.Config, - "users": plugin.Users, - "all_users": plugin.AllUsers, - "enabled": plugin.Enabled, - "last_error": plugin.LastError, - "sha256": plugin.SHA256, - "created_at": time.Now(), - "updated_at": plugin.UpdatedAt, + "id": plugin.ID, + "path": plugin.Path, + "manifest": plugin.Manifest, + "config": plugin.Config, + "users": plugin.Users, + "all_users": plugin.AllUsers, + "libraries": plugin.Libraries, + "all_libraries": plugin.AllLibraries, + "enabled": plugin.Enabled, + "last_error": plugin.LastError, + "sha256": plugin.SHA256, + "created_at": time.Now(), + "updated_at": plugin.UpdatedAt, }).Execute() return err } diff --git a/plugins/host_library.go b/plugins/host_library.go index 62eb5d647..3d9f61b4f 100644 --- a/plugins/host_library.go +++ b/plugins/host_library.go @@ -11,17 +11,32 @@ import ( type libraryServiceImpl struct { ds model.DataStore hasFilesystemPerm bool + allowedLibraryIDs []int + allLibraries bool + libraryIDMap map[int]struct{} } -func newLibraryService(ds model.DataStore, perm *LibraryPermission) host.LibraryService { +func newLibraryService(ds model.DataStore, perm *LibraryPermission, allowedLibraryIDs []int, allLibraries bool) host.LibraryService { hasFS := perm != nil && perm.Filesystem + libraryIDMap := make(map[int]struct{}) + for _, id := range allowedLibraryIDs { + libraryIDMap[id] = struct{}{} + } return &libraryServiceImpl{ ds: ds, hasFilesystemPerm: hasFS, + allowedLibraryIDs: allowedLibraryIDs, + allLibraries: allLibraries, + libraryIDMap: libraryIDMap, } } func (s *libraryServiceImpl) GetLibrary(ctx context.Context, id int32) (*host.Library, error) { + // Check if the library is accessible + if !s.isLibraryAccessible(int(id)) { + return nil, fmt.Errorf("library not accessible: library ID %d is not in the allowed list", id) + } + lib, err := s.ds.Library(ctx).Get(int(id)) if err != nil { return nil, fmt.Errorf("library not found: %w", err) @@ -30,15 +45,27 @@ func (s *libraryServiceImpl) GetLibrary(ctx context.Context, id int32) (*host.Li return s.convertLibrary(lib), nil } +// isLibraryAccessible checks if a library ID is accessible to this plugin. +func (s *libraryServiceImpl) isLibraryAccessible(id int) bool { + if s.allLibraries { + return true + } + _, ok := s.libraryIDMap[id] + return ok +} + func (s *libraryServiceImpl) GetAllLibraries(ctx context.Context) ([]host.Library, error) { libs, err := s.ds.Library(ctx).GetAll() if err != nil { return nil, fmt.Errorf("failed to get libraries: %w", err) } - result := make([]host.Library, len(libs)) - for i, lib := range libs { - result[i] = *s.convertLibrary(&lib) + // Filter libraries based on allowed list + var result []host.Library + for _, lib := range libs { + if s.isLibraryAccessible(lib.ID) { + result = append(result, *s.convertLibrary(&lib)) + } } return result, nil diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index 5eb896e09..413fc81c8 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -36,7 +36,7 @@ var _ = Describe("LibraryService", Ordered, func() { Describe("GetLibrary", func() { It("should return library metadata without filesystem permission", func() { reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl) lib := &model.Library{ ID: 1, @@ -68,7 +68,7 @@ var _ = Describe("LibraryService", Ordered, func() { It("should return library metadata with filesystem permission", func() { reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl) lib := &model.Library{ ID: 2, @@ -94,7 +94,7 @@ var _ = Describe("LibraryService", Ordered, func() { It("should return error for non-existent library", func() { reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason}).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: &reason}, nil, true).(*libraryServiceImpl) mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{}) @@ -108,7 +108,7 @@ var _ = Describe("LibraryService", Ordered, func() { Describe("GetAllLibraries", func() { It("should return all libraries without filesystem permission", func() { reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -131,7 +131,7 @@ var _ = Describe("LibraryService", Ordered, func() { It("should return all libraries with filesystem permission", func() { reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -151,6 +151,103 @@ var _ = Describe("LibraryService", Ordered, func() { }) }) + Describe("Library Access Filtering", func() { + It("should only return libraries in the allowed list", func() { + reason := "test" + // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + + libs := model.Libraries{ + {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, + {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, + {ID: 3, Name: "Classical", Path: "/music/classical", TotalSongs: 75}, + } + + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo.SetData(libs) + + results, err := service.GetAllLibraries(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal(int32(2))) + Expect(results[0].Name).To(Equal("Jazz")) + }) + + It("should return error when getting a library not in the allowed list", func() { + reason := "test" + // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + + libs := model.Libraries{ + {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, + {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, + } + + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo.SetData(libs) + + // Requesting library 1 which is not in the allowed list + _, err := service.GetLibrary(ctx, 1) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not accessible")) + }) + + It("should allow access to a library in the allowed list", func() { + reason := "test" + // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + + libs := model.Libraries{ + {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, + {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, + } + + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo.SetData(libs) + + result, err := service.GetLibrary(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(result.ID).To(Equal(int32(2))) + Expect(result.Name).To(Equal("Jazz")) + }) + + It("should return empty list when no libraries are allowed and allLibraries is false", func() { + reason := "test" + // No libraries allowed + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{}, false).(*libraryServiceImpl) + + libs := model.Libraries{ + {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, + {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, + } + + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo.SetData(libs) + + results, err := service.GetAllLibraries(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(0)) + }) + + It("should return all libraries when allLibraries is true regardless of allowed list", func() { + reason := "test" + // allLibraries=true should ignore the allowed list + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{1}, true).(*libraryServiceImpl) + + libs := model.Libraries{ + {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, + {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, + } + + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) + mockLibRepo.SetData(libs) + + results, err := service.GetAllLibraries(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + }) + }) + Describe("Plugin Integration", func() { var ( manager *Manager @@ -269,10 +366,11 @@ var _ = Describe("LibraryService Integration", Ordered, func() { mockPluginRepo := tests.CreateMockPluginRepo() mockPluginRepo.Permitted = true mockPluginRepo.SetData(model.Plugins{{ - ID: "test-library", - Path: destPath, - SHA256: hashHex, - Enabled: true, + ID: "test-library", + Path: destPath, + SHA256: hashHex, + Enabled: true, + AllLibraries: true, // Grant access to all libraries for testing }}) mockLibraryRepo := &tests.MockLibraryRepo{} diff --git a/plugins/manager.go b/plugins/manager.go index 779cf1485..0fe0ecf4e 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -399,6 +399,17 @@ func (m *Manager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, a }) } +// UpdatePluginLibraries updates the libraries permission settings for a plugin. +// If the plugin is enabled, it will be reloaded with the new settings. +// If the plugin requires library permission and no libraries are configured (and allLibraries is false), +// the plugin will be automatically disabled. +func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error { + return m.updatePluginSettings(ctx, id, func(p *model.Plugin) { + p.Libraries = librariesJSON + p.AllLibraries = allLibraries + }) +} + // updatePluginSettings is a common implementation for updating plugin settings. // The updateFn is called to apply the specific field updates to the plugin. // If the plugin is enabled, it will be reloaded. If users permission is required @@ -422,19 +433,25 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn updateFn(plugin) plugin.UpdatedAt = time.Now() - // Check if plugin requires users permission and if it's still satisfied + // Check if plugin requires permission and if it's still satisfied shouldDisable := false + disableReason := "" if wasEnabled { manifest, err := readManifest(plugin.Path) - if err == nil && manifest.Permissions != nil && manifest.Permissions.Users != nil { - if !hasValidUsersConfig(plugin.Users, plugin.AllUsers) { + if err == nil && manifest.Permissions != nil { + if manifest.Permissions.Users != nil && !hasValidUsersConfig(plugin.Users, plugin.AllUsers) { shouldDisable = true + disableReason = "users permission removal" + } + if manifest.Permissions.Library != nil && !hasValidLibrariesConfig(plugin.Libraries, plugin.AllLibraries) { + shouldDisable = true + disableReason = "library permission removal" } } } if shouldDisable { - // Disable the plugin since users permission is no longer satisfied + // Disable the plugin since permission is no longer satisfied if err := m.unloadPlugin(id); err != nil { log.Debug(ctx, "Plugin was not loaded", "plugin", id) } @@ -442,7 +459,7 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn if err := repo.Put(plugin); err != nil { return fmt.Errorf("updating plugin in DB: %w", err) } - log.Info(ctx, "Disabled plugin due to users permission removal", "plugin", id) + log.Info(ctx, "Disabled plugin due to "+disableReason, "plugin", id) m.sendPluginRefreshEvent(ctx, id) return nil } @@ -519,6 +536,13 @@ func (m *Manager) checkPermissionGates(p *model.Plugin) error { } } + // Check library permission gate + if manifest.Permissions != nil && manifest.Permissions.Library != nil { + if !hasValidLibrariesConfig(p.Libraries, p.AllLibraries) { + return fmt.Errorf("library permission requires configuration: select libraries or enable 'all libraries' access") + } + } + return nil } @@ -537,3 +561,19 @@ func hasValidUsersConfig(usersJSON string, allUsers bool) bool { } return len(users) > 0 } + +// hasValidLibrariesConfig checks if a plugin has valid libraries configuration. +// Returns true if allLibraries is true, or if librariesJSON contains at least one library. +func hasValidLibrariesConfig(librariesJSON string, allLibraries bool) bool { + if allLibraries { + return true + } + if librariesJSON == "" { + return false + } + var libraries []int + if err := json.Unmarshal([]byte(librariesJSON), &libraries); err != nil { + return false + } + return len(libraries) > 0 +} diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 285d03bd4..adc391660 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -20,12 +20,14 @@ import ( // serviceContext provides dependencies needed by host service factories. type serviceContext struct { - pluginName string - manager *Manager - permissions *Permissions - config map[string]string - allowedUsers []string // User IDs this plugin can access - allUsers bool // If true, plugin can access all users + pluginName string + manager *Manager + permissions *Permissions + config map[string]string + allowedUsers []string // User IDs this plugin can access + allUsers bool // If true, plugin can access all users + allowedLibraries []int // Library IDs this plugin can access + allLibraries bool // If true, plugin can access all libraries } // hostServiceEntry defines a host service for table-driven registration. @@ -92,7 +94,7 @@ var hostServices = []hostServiceEntry{ hasPermission: func(p *Permissions) bool { return p != nil && p.Library != nil }, create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { perm := ctx.permissions.Library - service := newLibraryService(ctx.manager.ds, perm) + service := newLibraryService(ctx.manager.ds, perm, ctx.allowedLibraries, ctx.allLibraries) return host.RegisterLibraryHostFunctions(service), nil }, }, @@ -243,6 +245,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { } } + // Parse libraries from JSON + var allowedLibraries []int + if p.Libraries != "" { + if err := json.Unmarshal([]byte(p.Libraries), &allowedLibraries); err != nil { + return fmt.Errorf("parsing plugin libraries: %w", err) + } + } + // Open the .ndp package to get manifest and wasm bytes pkg, err := openPackage(p.Path) if err != nil { @@ -270,9 +280,20 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("failed to get libraries for filesystem access: %w", err) } + // Build a set of allowed library IDs for fast lookup + allowedLibrarySet := make(map[int]struct{}, len(allowedLibraries)) + for _, id := range allowedLibraries { + allowedLibrarySet[id] = struct{}{} + } + allowedPaths := make(map[string]string) for _, lib := range libraries { - allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID)) + // Only mount if allLibraries is true or library is in the allowed list + if p.AllLibraries { + allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID)) + } else if _, ok := allowedLibrarySet[lib.ID]; ok { + allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID)) + } } pluginManifest.AllowedPaths = allowedPaths } @@ -282,12 +303,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { var closers []io.Closer svcCtx := &serviceContext{ - pluginName: p.ID, - manager: m, - permissions: pkg.Manifest.Permissions, - config: pluginConfig, - allowedUsers: allowedUsers, - allUsers: p.AllUsers, + pluginName: p.ID, + manager: m, + permissions: pkg.Manifest.Permissions, + config: pluginConfig, + allowedUsers: allowedUsers, + allUsers: p.AllUsers, + allowedLibraries: allowedLibraries, + allLibraries: p.AllLibraries, } for _, entry := range hostServices { if entry.hasPermission(pkg.Manifest.Permissions) { diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 8377d12c2..db17302e5 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -27,6 +27,7 @@ type PluginManager interface { DisablePlugin(ctx context.Context, id string) error UpdatePluginConfig(ctx context.Context, id, configJSON string) error UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error + UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error } type Router struct { diff --git a/server/nativeapi/plugin.go b/server/nativeapi/plugin.go index 2550dd34e..cebfb9600 100644 --- a/server/nativeapi/plugin.go +++ b/server/nativeapi/plugin.go @@ -43,10 +43,12 @@ func pluginsEnabledMiddleware(next http.Handler) http.Handler { // PluginUpdateRequest represents the fields that can be updated via the API type PluginUpdateRequest struct { - Enabled *bool `json:"enabled,omitempty"` - Config *string `json:"config,omitempty"` - Users *string `json:"users,omitempty"` - AllUsers *bool `json:"allUsers,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Config *string `json:"config,omitempty"` + Users *string `json:"users,omitempty"` + AllUsers *bool `json:"allUsers,omitempty"` + Libraries *string `json:"libraries,omitempty"` + AllLibraries *bool `json:"allLibraries,omitempty"` } func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { @@ -93,6 +95,14 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { } } + // Handle libraries permission update (if provided) + if req.Libraries != nil || req.AllLibraries != nil { + if err := validateAndUpdateLibraries(ctx, api.pluginManager, repo, id, req, w); err != nil { + log.Error(ctx, "Error updating plugin libraries", err) + return + } + } + // Handle enable/disable if req.Enabled != nil { if *req.Enabled { @@ -195,3 +205,36 @@ func validateAndUpdateUsers(ctx context.Context, pm PluginManager, repo model.Pl } return nil } + +// validateAndUpdateLibraries validates the libraries JSON and updates the plugin. +// Returns an error if validation or update fails (error response already written). +func validateAndUpdateLibraries(ctx context.Context, pm PluginManager, repo model.PluginRepository, id string, req PluginUpdateRequest, w http.ResponseWriter) error { + // Get current values if not provided in request + plugin, err := repo.Get(id) + if err != nil { + log.Error(ctx, "Error getting plugin for libraries update", "id", id, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return err + } + + librariesJSON := plugin.Libraries + allLibraries := plugin.AllLibraries + + if req.Libraries != nil { + if *req.Libraries != "" && !isValidJSON(*req.Libraries) { + http.Error(w, "Invalid JSON in libraries field", http.StatusBadRequest) + return errors.New("invalid JSON") + } + librariesJSON = *req.Libraries + } + if req.AllLibraries != nil { + allLibraries = *req.AllLibraries + } + + if err := pm.UpdatePluginLibraries(ctx, id, librariesJSON, allLibraries); err != nil { + log.Error(ctx, "Error updating plugin libraries", "id", id, err) + http.Error(w, "Error updating plugin libraries: "+err.Error(), http.StatusInternalServerError) + return err + } + return nil +} diff --git a/tests/mock_plugin_manager.go b/tests/mock_plugin_manager.go index 553946556..c57b3645d 100644 --- a/tests/mock_plugin_manager.go +++ b/tests/mock_plugin_manager.go @@ -5,7 +5,7 @@ import ( ) // MockPluginManager is a mock implementation of plugins.PluginManager for testing. -// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, and UpdatePluginUsers methods. +// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, UpdatePluginUsers and UpdatePluginLibraries methods. type MockPluginManager struct { // EnablePluginFn is called when EnablePlugin is invoked. If nil, returns EnableError. EnablePluginFn func(ctx context.Context, id string) error @@ -15,12 +15,15 @@ type MockPluginManager struct { UpdatePluginConfigFn func(ctx context.Context, id, configJSON string) error // UpdatePluginUsersFn is called when UpdatePluginUsers is invoked. If nil, returns UsersError. UpdatePluginUsersFn func(ctx context.Context, id, usersJSON string, allUsers bool) error + // UpdatePluginLibrariesFn is called when UpdatePluginLibraries is invoked. If nil, returns LibrariesError. + UpdatePluginLibrariesFn func(ctx context.Context, id, librariesJSON string, allLibraries bool) error // Default errors to return when Fn callbacks are not set - EnableError error - DisableError error - ConfigError error - UsersError error + EnableError error + DisableError error + ConfigError error + UsersError error + LibrariesError error // Track calls for assertions EnablePluginCalls []string @@ -34,6 +37,11 @@ type MockPluginManager struct { UsersJSON string AllUsers bool } + UpdatePluginLibrariesCalls []struct { + ID string + LibrariesJSON string + AllLibraries bool + } } func (m *MockPluginManager) EnablePlugin(ctx context.Context, id string) error { @@ -74,3 +82,15 @@ func (m *MockPluginManager) UpdatePluginUsers(ctx context.Context, id, usersJSON } return m.UsersError } + +func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error { + m.UpdatePluginLibrariesCalls = append(m.UpdatePluginLibrariesCalls, struct { + ID string + LibrariesJSON string + AllLibraries bool + }{ID: id, LibrariesJSON: librariesJSON, AllLibraries: allLibraries}) + if m.UpdatePluginLibrariesFn != nil { + return m.UpdatePluginLibrariesFn(ctx, id, librariesJSON, allLibraries) + } + return m.LibrariesError +} diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 03169dead..9bd410c8d 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -351,14 +351,17 @@ "configKey": "Key", "configValue": "Value", "allUsers": "Allow all users", - "selectedUsers": "Selected users" + "selectedUsers": "Selected users", + "allLibraries": "Allow all libraries", + "selectedLibraries": "Selected libraries" }, "sections": { "status": "Status", "info": "Plugin Information", "configuration": "Configuration", "manifest": "Manifest", - "usersPermission": "Users Permission" + "usersPermission": "Users Permission", + "libraryPermission": "Library Permission" }, "status": { "enabled": "Enabled", @@ -369,6 +372,7 @@ "disable": "Disable", "disabledDueToError": "Fix the error before enabling", "disabledUsersRequired": "Select users before enabling", + "disabledLibrariesRequired": "Select libraries before enabling", "addConfig": "Add Configuration" }, "notifications": { @@ -387,7 +391,10 @@ "allUsersHelp": "When enabled, the plugin will have access to all users, including those created in the future.", "noUsers": "No users selected", "permissionReason": "Reason", - "usersRequired": "This plugin requires access to user information. Select which users the plugin can access, or enable 'Allow all users'." + "usersRequired": "This plugin requires access to user information. Select which users the plugin can access, or enable 'Allow all users'.", + "allLibrariesHelp": "When enabled, the plugin will have access to all libraries, including those created in the future.", + "noLibraries": "No libraries selected", + "librariesRequired": "This plugin requires access to library information. Select which libraries the plugin can access, or enable 'Allow all libraries'." }, "placeholders": { "configKey": "key", diff --git a/ui/src/plugin/LibraryPermissionCard.jsx b/ui/src/plugin/LibraryPermissionCard.jsx new file mode 100644 index 000000000..885ac010b --- /dev/null +++ b/ui/src/plugin/LibraryPermissionCard.jsx @@ -0,0 +1,171 @@ +import React from 'react' +import { + Card, + CardContent, + Typography, + Box, + FormControlLabel, + Switch, + List, + ListItem, + ListItemIcon, + ListItemText, + Checkbox, +} from '@material-ui/core' +import CheckBoxIcon from '@material-ui/icons/CheckBox' +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank' +import Alert from '@material-ui/lab/Alert' +import { useGetList, useTranslate } from 'react-admin' +import PropTypes from 'prop-types' + +export const LibraryPermissionCard = ({ + manifest, + classes, + selectedLibraries, + allLibraries, + onSelectedLibrariesChange, + onAllLibrariesChange, +}) => { + const translate = useTranslate() + + // Fetch all libraries + const { data: librariesData, loading: librariesLoading } = useGetList( + 'library', + { + pagination: { page: 1, perPage: 1000 }, + sort: { field: 'name', order: 'ASC' }, + }, + ) + + const libraries = React.useMemo(() => { + return librariesData ? Object.values(librariesData) : [] + }, [librariesData]) + + const handleToggleLibrary = React.useCallback( + (libraryId) => { + const newSelected = selectedLibraries.includes(libraryId) + ? selectedLibraries.filter((id) => id !== libraryId) + : [...selectedLibraries, libraryId] + onSelectedLibrariesChange(newSelected) + }, + [selectedLibraries, onSelectedLibrariesChange], + ) + + const handleAllLibrariesToggle = React.useCallback( + (event) => { + onAllLibrariesChange(event.target.checked) + }, + [onAllLibrariesChange], + ) + + // Get permission reason from manifest + const libraryPermission = manifest?.permissions?.library + const reason = libraryPermission?.reason + + // Check if permission is required but not configured + const isConfigurationRequired = + libraryPermission && !allLibraries && selectedLibraries.length === 0 + + if (!libraryPermission) { + return null + } + + return ( + + + + {translate('resources.plugin.sections.libraryPermission')} + + + {reason && ( + + {translate('resources.plugin.messages.permissionReason')}: {reason} + + )} + + {isConfigurationRequired && ( + + + {translate('resources.plugin.messages.librariesRequired')} + + + )} + + + + } + label={translate('resources.plugin.fields.allLibraries')} + /> + + {translate('resources.plugin.messages.allLibrariesHelp')} + + + + {!allLibraries && ( + + + {translate('resources.plugin.fields.selectedLibraries')} + + {librariesLoading ? ( + + {translate('ra.message.loading')} + + ) : libraries.length === 0 ? ( + + {translate('resources.plugin.messages.noLibraries')} + + ) : ( + + {libraries.map((library) => ( + handleToggleLibrary(library.id)} + dense + > + + } + checkedIcon={} + checked={selectedLibraries.includes(library.id)} + tabIndex={-1} + disableRipple + /> + + + + ))} + + )} + + )} + + + ) +} + +LibraryPermissionCard.propTypes = { + manifest: PropTypes.object, + classes: PropTypes.object.isRequired, + selectedLibraries: PropTypes.array.isRequired, + allLibraries: PropTypes.bool.isRequired, + onSelectedLibrariesChange: PropTypes.func.isRequired, + onAllLibrariesChange: PropTypes.func.isRequired, +} diff --git a/ui/src/plugin/PluginShow.jsx b/ui/src/plugin/PluginShow.jsx index 273474897..805549dfc 100644 --- a/ui/src/plugin/PluginShow.jsx +++ b/ui/src/plugin/PluginShow.jsx @@ -21,6 +21,7 @@ import { InfoCard } from './InfoCard' import { ManifestSection } from './ManifestSection' import { ConfigCard } from './ConfigCard' import { UsersPermissionCard } from './UsersPermissionCard' +import { LibraryPermissionCard } from './LibraryPermissionCard' // Main show layout component const PluginShowLayout = () => { @@ -42,6 +43,12 @@ const PluginShowLayout = () => { const [lastRecordUsers, setLastRecordUsers] = useState(null) const [lastRecordAllUsers, setLastRecordAllUsers] = useState(null) + // Libraries permission state + const [selectedLibraries, setSelectedLibraries] = useState([]) + const [allLibraries, setAllLibraries] = useState(false) + const [lastRecordLibraries, setLastRecordLibraries] = useState(null) + const [lastRecordAllLibraries, setLastRecordAllLibraries] = useState(null) + // Convert JSON config to key-value pairs const jsonToPairs = useCallback((jsonString) => { if (!jsonString || jsonString.trim() === '') return [] @@ -100,6 +107,30 @@ const PluginShowLayout = () => { } }, [record, lastRecordUsers, lastRecordAllUsers, isDirty]) + // Initialize/update libraries permission state when record loads or changes + React.useEffect(() => { + if (record && !isDirty) { + const recordLibraries = record.libraries || '' + const recordAllLibraries = record.allLibraries || false + + if ( + recordLibraries !== lastRecordLibraries || + recordAllLibraries !== lastRecordAllLibraries + ) { + try { + setSelectedLibraries( + recordLibraries ? JSON.parse(recordLibraries) : [], + ) + } catch { + setSelectedLibraries([]) + } + setAllLibraries(recordAllLibraries) + setLastRecordLibraries(recordLibraries) + setLastRecordAllLibraries(recordAllLibraries) + } + } + }, [record, lastRecordLibraries, lastRecordAllLibraries, isDirty]) + const handleConfigPairsChange = useCallback((newPairs) => { setConfigPairs(newPairs) setIsDirty(true) @@ -115,6 +146,16 @@ const PluginShowLayout = () => { setIsDirty(true) }, []) + const handleSelectedLibrariesChange = useCallback((newSelectedLibraries) => { + setSelectedLibraries(newSelectedLibraries) + setIsDirty(true) + }, []) + + const handleAllLibrariesChange = useCallback((newAllLibraries) => { + setAllLibraries(newAllLibraries) + setIsDirty(true) + }, []) + const [updatePlugin, { loading }] = useUpdate( 'plugin', record?.id, @@ -128,6 +169,8 @@ const PluginShowLayout = () => { setLastRecordConfig(null) // Reset to reinitialize from server setLastRecordUsers(null) setLastRecordAllUsers(null) + setLastRecordLibraries(null) + setLastRecordAllLibraries(null) notify('resources.plugin.notifications.updated', 'info') }, onFailure: (err) => { @@ -151,8 +194,23 @@ const PluginShowLayout = () => { data.allUsers = allUsers } + // Include libraries data if library permission is present + if (manifest?.permissions?.library) { + data.libraries = JSON.stringify(selectedLibraries) + data.allLibraries = allLibraries + } + updatePlugin('plugin', record.id, data, record) - }, [updatePlugin, record, configPairs, pairsToJson, selectedUsers, allUsers]) + }, [ + updatePlugin, + record, + configPairs, + pairsToJson, + selectedUsers, + allUsers, + selectedLibraries, + allLibraries, + ]) // Parse manifest const { manifest, manifestJson } = useMemo(() => { @@ -230,6 +288,15 @@ const PluginShowLayout = () => { onAllUsersChange={handleAllUsersChange} /> + +