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}
/>
+
+