navidrome/plugins/manager_plugin.go

145 lines
4.7 KiB
Go

package plugins
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/model"
"github.com/tetratelabs/wazero"
"golang.org/x/sync/singleflight"
)
// plugin represents a loaded plugin
type plugin struct {
name string // Plugin name (from filename)
path string // Path to the wasm file
manifest *Manifest
compiled *extism.CompiledPlugin
capabilities []Capability // Auto-detected capabilities based on exported functions
closers []io.Closer // Cleanup functions to call on unload
metrics PluginMetricsRecorder
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
allUsers bool // If true, plugin can access all users
libraries libraryAccess
lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics)
lyricsCalls singleflight.Group // Shared by the transient LyricsPlugin adapters
fsConfig wazero.FSConfig // Sandboxed library mounts, nil if no filesystem permission
}
// instanceConfig is used by every call site, so all instances get the sandboxed mounts.
func instanceConfig(fsConfig wazero.FSConfig) extism.PluginInstanceConfig {
moduleConfig := wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader)
if fsConfig != nil {
moduleConfig = moduleConfig.WithFSConfig(fsConfig)
}
return extism.PluginInstanceConfig{ModuleConfig: moduleConfig}
}
// instance creates a new plugin instance for the given context.
// The context is used for cancellation - if cancelled during a call,
// the module will be terminated and the instance becomes unusable.
func (p *plugin) instance(ctx context.Context) (*extism.Plugin, error) {
instance, err := p.compiled.Instance(ctx, instanceConfig(p.fsConfig))
if err != nil {
return nil, err
}
instance.SetLogger(extismLogger(p.name))
return instance, nil
}
func (p *plugin) Close() error {
var errs []error
for _, f := range p.closers {
err := f.Close()
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (p *plugin) hasLibraryFilesystemAccess(libID int) bool {
return p.manifest.HasLibraryFilesystemPermission() && p.libraries.contains(libID)
}
// libraryAccess captures the set of libraries a plugin is permitted to see,
// precomputed at load time for O(1) lookup.
type libraryAccess struct {
allLibraries bool
libraryIDSet map[int]struct{}
}
func newLibraryAccess(allowedLibraryIDs []int, allLibraries bool) libraryAccess {
set := make(map[int]struct{}, len(allowedLibraryIDs))
for _, id := range allowedLibraryIDs {
set[id] = struct{}{}
}
return libraryAccess{allLibraries: allLibraries, libraryIDSet: set}
}
func (a libraryAccess) contains(libID int) bool {
if a.allLibraries {
return true
}
_, ok := a.libraryIDSet[libID]
return ok
}
// configured reports whether the plugin has any library scope (all, or specific).
func (a libraryAccess) configured() bool {
return a.allLibraries || len(a.libraryIDSet) > 0
}
// userAccess captures the set of users a plugin is permitted to act as,
// precomputed at load time for O(1) lookup.
type userAccess struct {
allUsers bool
userIDSet map[string]struct{}
}
func newUserAccess(allowedUserIDs []string, allUsers bool) userAccess {
set := make(map[string]struct{}, len(allowedUserIDs))
for _, id := range allowedUserIDs {
set[id] = struct{}{}
}
return userAccess{allUsers: allUsers, userIDSet: set}
}
// allows reports whether the plugin may act as the given user ID.
func (a userAccess) allows(userID string) bool {
if a.allUsers {
return true
}
_, ok := a.userIDSet[userID]
return ok
}
// resolve looks up a user by username and authorizes it against this access set,
// distinguishing an absent user from a backend failure.
//
// When the plugin has no user scope at all, it rejects before the lookup with a
// single fixed error, so a caller cannot tell a real account from a missing one by
// the error text (username enumeration).
func (a userAccess) resolve(ctx context.Context, ds model.DataStore, username string) (*model.User, error) {
if !a.allUsers && len(a.userIDSet) == 0 {
return nil, fmt.Errorf("plugin is not authorized to scope by user")
}
usr, err := ds.User(ctx).FindByUsername(username)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, fmt.Errorf("user %q not found", username)
}
return nil, fmt.Errorf("looking up user %q: %w", username, err)
}
if usr == nil { // defensive: a conforming repo returns ErrNotFound, not (nil, nil)
return nil, fmt.Errorf("user %q not found", username)
}
if !a.allows(usr.ID) {
return nil, fmt.Errorf("plugin is not allowed to act as user %q", username)
}
return usr, nil
}