mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(plugins): add capability detection for plugins based on exported functions
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
b3ec005fa2
commit
22561abadc
2
Makefile
2
Makefile
@ -85,7 +85,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
|
||||
PATH=$$PATH:./bin golangci-lint run --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
|
||||
@ -44,34 +44,35 @@ Plugins must export an `nd_manifest` function that returns JSON:
|
||||
"version": "1.0.0",
|
||||
"description": "Plugin description",
|
||||
"website": "https://example.com",
|
||||
"capabilities": ["MetadataAgent"],
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "Fetch metadata from external API",
|
||||
"allowedUrls": {
|
||||
"https://api.example.com/*": ["GET"]
|
||||
}
|
||||
"allowedHosts": ["api.example.com", "*.musicbrainz.org"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Capabilities are auto-detected based on which functions the plugin exports. You don't need to declare them in the manifest.
|
||||
|
||||
## Capabilities
|
||||
|
||||
Capabilities are automatically detected by examining which functions a plugin exports. There's no need to declare capabilities in the manifest.
|
||||
|
||||
### MetadataAgent
|
||||
|
||||
Provides artist and album metadata. Implement one or more of these functions:
|
||||
Provides artist and album metadata. A plugin has this capability if it exports one or more of these functions:
|
||||
|
||||
| Function | Input | Output | Description |
|
||||
|----------|-------|--------|-------------|
|
||||
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
|
||||
| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
|
||||
| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
|
||||
| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
|
||||
| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
|
||||
| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
|
||||
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
|
||||
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
|
||||
| Function | Input | Output | Description |
|
||||
|---------------------------|----------------------------|----------------------------------|----------------------|
|
||||
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
|
||||
| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
|
||||
| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
|
||||
| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
|
||||
| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
|
||||
| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
|
||||
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
|
||||
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
|
||||
|
||||
## Developing Plugins
|
||||
|
||||
@ -88,19 +89,17 @@ import (
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "My Plugin",
|
||||
Author: "Me",
|
||||
Version: "1.0.0",
|
||||
Capabilities: []string{"MetadataAgent"},
|
||||
Name: "My Plugin",
|
||||
Author: "Me",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
out, _ := json.Marshal(manifest)
|
||||
pdk.Output(out)
|
||||
@ -143,7 +142,7 @@ tinygo build -o my-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go
|
||||
|
||||
### Using HTTP
|
||||
|
||||
Plugins can make HTTP requests using the Extism PDK. The host controls which URLs are allowed via the `permissions.http.allowedUrls` manifest field.
|
||||
Plugins can make HTTP requests using the Extism PDK. The host controls which hosts are allowed via the `permissions.http.allowedHosts` manifest field.
|
||||
|
||||
```go
|
||||
//go:wasmexport nd_get_artist_biography
|
||||
@ -177,7 +176,7 @@ if !ok {
|
||||
|
||||
Plugins run in a secure WebAssembly sandbox with these restrictions:
|
||||
|
||||
1. **URL Allowlisting**: Only URLs listed in `permissions.http.allowedUrls` are accessible
|
||||
1. **Host Allowlisting**: Only hosts listed in `permissions.http.allowedHosts` are accessible
|
||||
2. **No File System Access**: Plugins cannot access the file system
|
||||
3. **No Network Listeners**: Plugins cannot bind ports or create servers
|
||||
4. **Config Isolation**: Plugins receive only their own config section
|
||||
|
||||
62
plugins/capabilities.go
Normal file
62
plugins/capabilities.go
Normal file
@ -0,0 +1,62 @@
|
||||
package plugins
|
||||
|
||||
// Capability represents a plugin capability type.
|
||||
// Capabilities are detected by checking which functions a plugin exports.
|
||||
type Capability string
|
||||
|
||||
const (
|
||||
// CapabilityMetadataAgent indicates the plugin can provide artist/album metadata.
|
||||
// Detected when the plugin exports at least one of the metadata agent functions.
|
||||
CapabilityMetadataAgent Capability = "MetadataAgent"
|
||||
|
||||
// Future capabilities:
|
||||
// CapabilityScrobbler Capability = "Scrobbler"
|
||||
)
|
||||
|
||||
// capabilityFunctions maps each capability to its required/optional functions.
|
||||
// A plugin has a capability if it exports at least one of these functions.
|
||||
var capabilityFunctions = map[Capability][]string{
|
||||
CapabilityMetadataAgent: {
|
||||
FuncGetArtistMBID,
|
||||
FuncGetArtistURL,
|
||||
FuncGetArtistBiography,
|
||||
FuncGetSimilarArtists,
|
||||
FuncGetArtistImages,
|
||||
FuncGetArtistTopSongs,
|
||||
FuncGetAlbumInfo,
|
||||
FuncGetAlbumImages,
|
||||
},
|
||||
}
|
||||
|
||||
// functionExistsChecker is an interface for checking if a function exists in a plugin.
|
||||
// This allows for testing without a real plugin instance.
|
||||
type functionExistsChecker interface {
|
||||
FunctionExists(name string) bool
|
||||
}
|
||||
|
||||
// detectCapabilities detects which capabilities a plugin has by checking
|
||||
// which functions it exports.
|
||||
func detectCapabilities(plugin functionExistsChecker) []Capability {
|
||||
var capabilities []Capability
|
||||
|
||||
for cap, functions := range capabilityFunctions {
|
||||
for _, fn := range functions {
|
||||
if plugin.FunctionExists(fn) {
|
||||
capabilities = append(capabilities, cap)
|
||||
break // Found at least one function, plugin has this capability
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return capabilities
|
||||
}
|
||||
|
||||
// hasCapability checks if the given capabilities slice contains a specific capability.
|
||||
func hasCapability(capabilities []Capability, cap Capability) bool {
|
||||
for _, c := range capabilities {
|
||||
if c == cap {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
81
plugins/capabilities_test.go
Normal file
81
plugins/capabilities_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// mockFunctionChecker implements functionExistsChecker for testing
|
||||
type mockFunctionChecker struct {
|
||||
functions map[string]bool
|
||||
}
|
||||
|
||||
func (m *mockFunctionChecker) FunctionExists(name string) bool {
|
||||
return m.functions[name]
|
||||
}
|
||||
|
||||
var _ = Describe("Capabilities", func() {
|
||||
Describe("detectCapabilities", func() {
|
||||
It("detects MetadataAgent capability when plugin exports artist biography function", func() {
|
||||
checker := &mockFunctionChecker{
|
||||
functions: map[string]bool{
|
||||
FuncGetArtistBiography: true,
|
||||
},
|
||||
}
|
||||
|
||||
caps := detectCapabilities(checker)
|
||||
Expect(caps).To(ContainElement(CapabilityMetadataAgent))
|
||||
})
|
||||
|
||||
It("detects MetadataAgent capability when plugin exports multiple functions", func() {
|
||||
checker := &mockFunctionChecker{
|
||||
functions: map[string]bool{
|
||||
FuncGetArtistMBID: true,
|
||||
FuncGetArtistURL: true,
|
||||
FuncGetAlbumInfo: true,
|
||||
FuncGetAlbumImages: true,
|
||||
},
|
||||
}
|
||||
|
||||
caps := detectCapabilities(checker)
|
||||
Expect(caps).To(ContainElement(CapabilityMetadataAgent))
|
||||
Expect(caps).To(HaveLen(1)) // Should only have one MetadataAgent capability
|
||||
})
|
||||
|
||||
It("returns empty slice when no capability functions are exported", func() {
|
||||
checker := &mockFunctionChecker{
|
||||
functions: map[string]bool{
|
||||
"some_other_function": true,
|
||||
},
|
||||
}
|
||||
|
||||
caps := detectCapabilities(checker)
|
||||
Expect(caps).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty slice when plugin exports no functions", func() {
|
||||
checker := &mockFunctionChecker{
|
||||
functions: map[string]bool{},
|
||||
}
|
||||
|
||||
caps := detectCapabilities(checker)
|
||||
Expect(caps).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("hasCapability", func() {
|
||||
It("returns true when capability exists", func() {
|
||||
caps := []Capability{CapabilityMetadataAgent}
|
||||
Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when capability does not exist", func() {
|
||||
caps := []Capability{}
|
||||
Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false when capabilities slice is nil", func() {
|
||||
Expect(hasCapability(nil, CapabilityMetadataAgent)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,6 +2,8 @@ package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@ -38,10 +40,11 @@ type Manager struct {
|
||||
|
||||
// pluginInstance represents a loaded plugin
|
||||
type pluginInstance struct {
|
||||
name string // Plugin name (from filename)
|
||||
path string // Path to the wasm file
|
||||
manifest *Manifest
|
||||
compiled *extism.CompiledPlugin
|
||||
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
|
||||
}
|
||||
|
||||
// GetManager returns a singleton instance of the plugin manager.
|
||||
@ -121,6 +124,7 @@ func (m *Manager) Stop() error {
|
||||
|
||||
// PluginNames returns the names of all plugins that implement a particular capability.
|
||||
// This is used by both agents and scrobbler systems to discover available plugins.
|
||||
// Capabilities are auto-detected from the plugin's exported functions.
|
||||
func (m *Manager) PluginNames(capability string) []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@ -128,7 +132,7 @@ func (m *Manager) PluginNames(capability string) []string {
|
||||
var names []string
|
||||
cap := Capability(capability)
|
||||
for name, instance := range m.plugins {
|
||||
if instance.manifest.HasCapability(cap) {
|
||||
if hasCapability(instance.capabilities, cap) {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
@ -142,7 +146,7 @@ func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
|
||||
instance, ok := m.plugins[name]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok || !instance.manifest.HasCapability(CapabilityMetadataAgent) {
|
||||
if !ok || !hasCapability(instance.capabilities, CapabilityMetadataAgent) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@ -256,7 +260,7 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
|
||||
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
|
||||
}
|
||||
|
||||
// Create temporary plugin to read manifest
|
||||
// Create temporary plugin to read manifest and detect capabilities
|
||||
tempPlugin, err := extism.NewPlugin(m.ctx, tempManifest, tempConfig, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -272,15 +276,15 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse and validate manifest
|
||||
manifest, err := ParseManifest(manifestBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return err
|
||||
// Parse manifest (validation happens during unmarshal via generated code)
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
|
||||
return fmt.Errorf("invalid plugin manifest: %w", err)
|
||||
}
|
||||
|
||||
// Detect capabilities based on exported functions
|
||||
capabilities := detectCapabilities(tempPlugin)
|
||||
|
||||
// Now create the final compiled plugin with proper AllowedHosts
|
||||
finalManifest := extism.Manifest{
|
||||
Wasm: []extism.Wasm{
|
||||
@ -306,10 +310,11 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
|
||||
|
||||
m.mu.Lock()
|
||||
m.plugins[name] = &pluginInstance{
|
||||
name: name,
|
||||
path: wasmPath,
|
||||
manifest: manifest,
|
||||
compiled: compiled,
|
||||
name: name,
|
||||
path: wasmPath,
|
||||
manifest: &manifest,
|
||||
compiled: compiled,
|
||||
capabilities: capabilities,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
|
||||
@ -1,182 +1,12 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Capability represents a plugin capability type
|
||||
type Capability string
|
||||
|
||||
const (
|
||||
CapabilityMetadataAgent Capability = "MetadataAgent"
|
||||
// Future capabilities:
|
||||
// CapabilityScrobbler Capability = "Scrobbler"
|
||||
)
|
||||
|
||||
// Manifest represents the plugin manifest exported by the nd_manifest function.
|
||||
// The manifest describes the plugin's metadata, capabilities, and permissions.
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
Permissions Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
// Permissions defines the plugin's required permissions
|
||||
type Permissions struct {
|
||||
HTTP *HTTPPermission `json:"http,omitempty"`
|
||||
Config *ConfigPermission `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPPermission defines HTTP access permissions for a plugin
|
||||
type HTTPPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AllowedURLs map[string][]string `json:"allowedUrls,omitempty"`
|
||||
}
|
||||
|
||||
// ConfigPermission defines config access permissions for a plugin
|
||||
type ConfigPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks if the manifest is valid
|
||||
func (m *Manifest) Validate() error {
|
||||
if m.Name == "" {
|
||||
return errors.New("plugin manifest: name is required")
|
||||
}
|
||||
if m.Author == "" {
|
||||
return errors.New("plugin manifest: author is required")
|
||||
}
|
||||
if m.Version == "" {
|
||||
return errors.New("plugin manifest: version is required")
|
||||
}
|
||||
if len(m.Capabilities) == 0 {
|
||||
return errors.New("plugin manifest: at least one capability is required")
|
||||
}
|
||||
|
||||
// Validate capabilities
|
||||
for _, cap := range m.Capabilities {
|
||||
if !isValidCapability(cap) {
|
||||
return fmt.Errorf("plugin manifest: unknown capability %q", cap)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate HTTP permissions if present
|
||||
if m.Permissions.HTTP != nil {
|
||||
if err := m.validateHTTPPermissions(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasCapability checks if the plugin has a specific capability
|
||||
func (m *Manifest) HasCapability(cap Capability) bool {
|
||||
for _, c := range m.Capabilities {
|
||||
if c == cap {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
//go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest.json
|
||||
|
||||
// AllowedHosts returns a list of allowed hosts for HTTP requests.
|
||||
// This extracts hostnames from the AllowedURLs patterns.
|
||||
// Returns the hosts directly from the manifest's permissions.
|
||||
func (m *Manifest) AllowedHosts() []string {
|
||||
if m.Permissions.HTTP == nil || len(m.Permissions.HTTP.AllowedURLs) == 0 {
|
||||
if m.Permissions == nil || m.Permissions.Http == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
hosts := make([]string, 0, len(m.Permissions.HTTP.AllowedURLs))
|
||||
for urlPattern := range m.Permissions.HTTP.AllowedURLs {
|
||||
host := extractHost(urlPattern)
|
||||
if host != "" {
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
// ParseManifest parses JSON data into a Manifest struct
|
||||
func ParseManifest(data []byte) (*Manifest, error) {
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("plugin manifest: invalid JSON: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// validateHTTPPermissions validates the HTTP permission configuration
|
||||
func (m *Manifest) validateHTTPPermissions() error {
|
||||
for urlPattern := range m.Permissions.HTTP.AllowedURLs {
|
||||
if !isValidURLPattern(urlPattern) {
|
||||
return fmt.Errorf("plugin manifest: invalid URL pattern %q", urlPattern)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidCapability checks if a capability is known
|
||||
func isValidCapability(cap Capability) bool {
|
||||
switch cap {
|
||||
case CapabilityMetadataAgent:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isValidURLPattern checks if a URL pattern is valid.
|
||||
// Valid patterns are URLs that may contain wildcards (*).
|
||||
// Examples:
|
||||
// - https://api.example.com/*
|
||||
// - https://*.example.com/api/*
|
||||
// - https://example.com/v1/endpoint
|
||||
func isValidURLPattern(pattern string) bool {
|
||||
// Remove wildcards temporarily to validate the base URL
|
||||
testURL := strings.ReplaceAll(pattern, "*", "wildcard")
|
||||
|
||||
u, err := url.Parse(testURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have a scheme (http or https)
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have a host
|
||||
if u.Host == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// extractHost extracts the hostname from a URL pattern.
|
||||
// For patterns with wildcards in the host, it returns the pattern as-is for glob matching.
|
||||
// Examples:
|
||||
// - https://api.example.com/* -> api.example.com
|
||||
// - https://*.example.com/api/* -> *.example.com
|
||||
func extractHost(pattern string) string {
|
||||
// Remove wildcards temporarily to parse the URL
|
||||
testURL := strings.ReplaceAll(pattern, "*", "wildcard")
|
||||
|
||||
u, err := url.Parse(testURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Restore wildcards in the host
|
||||
host := strings.ReplaceAll(u.Hostname(), "wildcard", "*")
|
||||
return host
|
||||
return m.Permissions.Http.AllowedHosts
|
||||
}
|
||||
|
||||
82
plugins/manifest.json
Normal file
82
plugins/manifest.json
Normal file
@ -0,0 +1,82 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://navidrome.org/schemas/Manifest.json",
|
||||
"title": "Manifest",
|
||||
"description": "Plugin manifest for Navidrome plugins",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "author", "version"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The display name of the plugin",
|
||||
"minLength": 1
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"description": "The author of the plugin",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The version of the plugin (semver recommended)",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A brief description of what the plugin does"
|
||||
},
|
||||
"website": {
|
||||
"type": "string",
|
||||
"description": "URL to the plugin's website or repository",
|
||||
"format": "uri"
|
||||
},
|
||||
"permissions": {
|
||||
"$ref": "#/$defs/Permissions"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"Permissions": {
|
||||
"type": "object",
|
||||
"description": "Permissions required by the plugin",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"http": {
|
||||
"$ref": "#/$defs/HTTPPermission"
|
||||
},
|
||||
"config": {
|
||||
"$ref": "#/$defs/ConfigPermission"
|
||||
}
|
||||
}
|
||||
},
|
||||
"HTTPPermission": {
|
||||
"type": "object",
|
||||
"description": "HTTP access permissions for a plugin",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Explanation for why HTTP access is needed"
|
||||
},
|
||||
"allowedHosts": {
|
||||
"type": "array",
|
||||
"description": "List of allowed host patterns for HTTP requests (e.g., 'api.example.com', '*.spotify.com')",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConfigPermission": {
|
||||
"type": "object",
|
||||
"description": "Configuration access permissions for a plugin",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Explanation for why config access is needed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
plugins/manifest_gen.go
Normal file
85
plugins/manifest_gen.go
Normal file
@ -0,0 +1,85 @@
|
||||
// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT.
|
||||
|
||||
package plugins
|
||||
|
||||
import "encoding/json"
|
||||
import "fmt"
|
||||
|
||||
// Configuration access permissions for a plugin
|
||||
type ConfigPermission struct {
|
||||
// Explanation for why config access is needed
|
||||
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// HTTP access permissions for a plugin
|
||||
type HTTPPermission struct {
|
||||
// List of allowed host patterns for HTTP requests (e.g., 'api.example.com',
|
||||
// '*.spotify.com')
|
||||
AllowedHosts []string `json:"allowedHosts,omitempty" yaml:"allowedHosts,omitempty" mapstructure:"allowedHosts,omitempty"`
|
||||
|
||||
// Explanation for why HTTP access is needed
|
||||
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Plugin manifest for Navidrome plugins
|
||||
type Manifest struct {
|
||||
// The author of the plugin
|
||||
Author string `json:"author" yaml:"author" mapstructure:"author"`
|
||||
|
||||
// A brief description of what the plugin does
|
||||
Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"`
|
||||
|
||||
// The display name of the plugin
|
||||
Name string `json:"name" yaml:"name" mapstructure:"name"`
|
||||
|
||||
// Permissions corresponds to the JSON schema field "permissions".
|
||||
Permissions *Permissions `json:"permissions,omitempty" yaml:"permissions,omitempty" mapstructure:"permissions,omitempty"`
|
||||
|
||||
// The version of the plugin (semver recommended)
|
||||
Version string `json:"version" yaml:"version" mapstructure:"version"`
|
||||
|
||||
// URL to the plugin's website or repository
|
||||
Website *string `json:"website,omitempty" yaml:"website,omitempty" mapstructure:"website,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (j *Manifest) UnmarshalJSON(value []byte) error {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(value, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := raw["author"]; raw != nil && !ok {
|
||||
return fmt.Errorf("field author in Manifest: required")
|
||||
}
|
||||
if _, ok := raw["name"]; raw != nil && !ok {
|
||||
return fmt.Errorf("field name in Manifest: required")
|
||||
}
|
||||
if _, ok := raw["version"]; raw != nil && !ok {
|
||||
return fmt.Errorf("field version in Manifest: required")
|
||||
}
|
||||
type Plain Manifest
|
||||
var plain Plain
|
||||
if err := json.Unmarshal(value, &plain); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(plain.Author) < 1 {
|
||||
return fmt.Errorf("field %s length: must be >= %d", "author", 1)
|
||||
}
|
||||
if len(plain.Name) < 1 {
|
||||
return fmt.Errorf("field %s length: must be >= %d", "name", 1)
|
||||
}
|
||||
if len(plain.Version) < 1 {
|
||||
return fmt.Errorf("field %s length: must be >= %d", "version", 1)
|
||||
}
|
||||
*j = Manifest(plain)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Permissions required by the plugin
|
||||
type Permissions struct {
|
||||
// Config corresponds to the JSON schema field "config".
|
||||
Config *ConfigPermission `json:"config,omitempty" yaml:"config,omitempty" mapstructure:"config,omitempty"`
|
||||
|
||||
// Http corresponds to the JSON schema field "http".
|
||||
Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"`
|
||||
}
|
||||
@ -1,12 +1,14 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Manifest", func() {
|
||||
Describe("ParseManifest", func() {
|
||||
Describe("UnmarshalJSON", func() {
|
||||
It("parses a valid manifest", func() {
|
||||
data := []byte(`{
|
||||
"name": "Test Plugin",
|
||||
@ -14,217 +16,133 @@ var _ = Describe("Manifest", func() {
|
||||
"version": "1.0.0",
|
||||
"description": "A test plugin",
|
||||
"website": "https://example.com",
|
||||
"capabilities": ["MetadataAgent"],
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "Fetch metadata",
|
||||
"allowedUrls": {
|
||||
"https://api.example.com/*": ["GET"]
|
||||
}
|
||||
"allowedHosts": ["api.example.com", "*.spotify.com"]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
m, err := ParseManifest(data)
|
||||
var m Manifest
|
||||
err := json.Unmarshal(data, &m)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m.Name).To(Equal("Test Plugin"))
|
||||
Expect(m.Author).To(Equal("Test Author"))
|
||||
Expect(m.Version).To(Equal("1.0.0"))
|
||||
Expect(m.Description).To(Equal("A test plugin"))
|
||||
Expect(m.Website).To(Equal("https://example.com"))
|
||||
Expect(m.Capabilities).To(ContainElement(CapabilityMetadataAgent))
|
||||
Expect(m.Permissions.HTTP).ToNot(BeNil())
|
||||
Expect(m.Permissions.HTTP.Reason).To(Equal("Fetch metadata"))
|
||||
Expect(m.Permissions.HTTP.AllowedURLs).To(HaveKey("https://api.example.com/*"))
|
||||
Expect(*m.Description).To(Equal("A test plugin"))
|
||||
Expect(*m.Website).To(Equal("https://example.com"))
|
||||
Expect(m.Permissions.Http).ToNot(BeNil())
|
||||
Expect(*m.Permissions.Http.Reason).To(Equal("Fetch metadata"))
|
||||
Expect(m.Permissions.Http.AllowedHosts).To(ContainElements("api.example.com", "*.spotify.com"))
|
||||
})
|
||||
|
||||
It("parses a minimal manifest", func() {
|
||||
data := []byte(`{
|
||||
"name": "Minimal Plugin",
|
||||
"author": "Author",
|
||||
"version": "1.0.0"
|
||||
}`)
|
||||
|
||||
var m Manifest
|
||||
err := json.Unmarshal(data, &m)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m.Name).To(Equal("Minimal Plugin"))
|
||||
Expect(m.Author).To(Equal("Author"))
|
||||
Expect(m.Version).To(Equal("1.0.0"))
|
||||
Expect(m.Description).To(BeNil())
|
||||
Expect(m.Permissions).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns an error for invalid JSON", func() {
|
||||
data := []byte(`{invalid json}`)
|
||||
|
||||
_, err := ParseManifest(data)
|
||||
var m Manifest
|
||||
err := json.Unmarshal(data, &m)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid JSON"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Validate", func() {
|
||||
It("returns an error when name is missing", func() {
|
||||
m := &Manifest{
|
||||
Author: "Test Author",
|
||||
Version: "1.0.0",
|
||||
Capabilities: []Capability{CapabilityMetadataAgent},
|
||||
}
|
||||
data := []byte(`{"author": "Test Author", "version": "1.0.0"}`)
|
||||
|
||||
err := m.Validate()
|
||||
var m Manifest
|
||||
err := json.Unmarshal(data, &m)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("name is required"))
|
||||
Expect(err.Error()).To(ContainSubstring("name"))
|
||||
})
|
||||
|
||||
It("returns an error when author is missing", func() {
|
||||
m := &Manifest{
|
||||
Name: "Test Plugin",
|
||||
Version: "1.0.0",
|
||||
Capabilities: []Capability{CapabilityMetadataAgent},
|
||||
}
|
||||
data := []byte(`{"name": "Test Plugin", "version": "1.0.0"}`)
|
||||
|
||||
err := m.Validate()
|
||||
var m Manifest
|
||||
err := json.Unmarshal(data, &m)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("author is required"))
|
||||
Expect(err.Error()).To(ContainSubstring("author"))
|
||||
})
|
||||
|
||||
It("returns an error when version is missing", func() {
|
||||
m := &Manifest{
|
||||
Name: "Test Plugin",
|
||||
Author: "Test Author",
|
||||
Capabilities: []Capability{CapabilityMetadataAgent},
|
||||
}
|
||||
data := []byte(`{"name": "Test Plugin", "author": "Test Author"}`)
|
||||
|
||||
err := m.Validate()
|
||||
var m Manifest
|
||||
err := json.Unmarshal(data, &m)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("version is required"))
|
||||
Expect(err.Error()).To(ContainSubstring("version"))
|
||||
})
|
||||
|
||||
It("returns an error when capabilities are missing", func() {
|
||||
m := &Manifest{
|
||||
Name: "Test Plugin",
|
||||
Author: "Test Author",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
It("returns an error when name is empty", func() {
|
||||
data := []byte(`{"name": "", "author": "Test Author", "version": "1.0.0"}`)
|
||||
|
||||
err := m.Validate()
|
||||
var m Manifest
|
||||
err := json.Unmarshal(data, &m)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("at least one capability is required"))
|
||||
Expect(err.Error()).To(ContainSubstring("name"))
|
||||
})
|
||||
|
||||
It("returns an error for unknown capability", func() {
|
||||
m := &Manifest{
|
||||
Name: "Test Plugin",
|
||||
Author: "Test Author",
|
||||
Version: "1.0.0",
|
||||
Capabilities: []Capability{"UnknownCapability"},
|
||||
}
|
||||
It("returns an error when author is empty", func() {
|
||||
data := []byte(`{"name": "Test Plugin", "author": "", "version": "1.0.0"}`)
|
||||
|
||||
err := m.Validate()
|
||||
var m Manifest
|
||||
err := json.Unmarshal(data, &m)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("unknown capability"))
|
||||
Expect(err.Error()).To(ContainSubstring("author"))
|
||||
})
|
||||
|
||||
It("returns an error for invalid URL pattern", func() {
|
||||
m := &Manifest{
|
||||
Name: "Test Plugin",
|
||||
Author: "Test Author",
|
||||
Version: "1.0.0",
|
||||
Capabilities: []Capability{CapabilityMetadataAgent},
|
||||
Permissions: Permissions{
|
||||
HTTP: &HTTPPermission{
|
||||
AllowedURLs: map[string][]string{
|
||||
"not-a-valid-url": {"GET"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
It("returns an error when version is empty", func() {
|
||||
data := []byte(`{"name": "Test Plugin", "author": "Test Author", "version": ""}`)
|
||||
|
||||
err := m.Validate()
|
||||
var m Manifest
|
||||
err := json.Unmarshal(data, &m)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid URL pattern"))
|
||||
})
|
||||
|
||||
It("validates a valid manifest", func() {
|
||||
m := &Manifest{
|
||||
Name: "Test Plugin",
|
||||
Author: "Test Author",
|
||||
Version: "1.0.0",
|
||||
Capabilities: []Capability{CapabilityMetadataAgent},
|
||||
Permissions: Permissions{
|
||||
HTTP: &HTTPPermission{
|
||||
AllowedURLs: map[string][]string{
|
||||
"https://api.example.com/*": {"GET"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := m.Validate()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("HasCapability", func() {
|
||||
It("returns true when capability exists", func() {
|
||||
m := &Manifest{
|
||||
Capabilities: []Capability{CapabilityMetadataAgent},
|
||||
}
|
||||
|
||||
Expect(m.HasCapability(CapabilityMetadataAgent)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when capability does not exist", func() {
|
||||
m := &Manifest{
|
||||
Capabilities: []Capability{},
|
||||
}
|
||||
|
||||
Expect(m.HasCapability(CapabilityMetadataAgent)).To(BeFalse())
|
||||
Expect(err.Error()).To(ContainSubstring("version"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AllowedHosts", func() {
|
||||
It("returns nil when no HTTP permissions", func() {
|
||||
It("returns nil when no permissions", func() {
|
||||
m := &Manifest{}
|
||||
|
||||
Expect(m.AllowedHosts()).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns nil when no allowed URLs", func() {
|
||||
It("returns nil when no HTTP permissions", func() {
|
||||
m := &Manifest{
|
||||
Permissions: Permissions{
|
||||
HTTP: &HTTPPermission{},
|
||||
},
|
||||
Permissions: &Permissions{},
|
||||
}
|
||||
|
||||
Expect(m.AllowedHosts()).To(BeNil())
|
||||
})
|
||||
|
||||
It("extracts hosts from URL patterns", func() {
|
||||
It("returns hosts from permissions", func() {
|
||||
m := &Manifest{
|
||||
Permissions: Permissions{
|
||||
HTTP: &HTTPPermission{
|
||||
AllowedURLs: map[string][]string{
|
||||
"https://api.example.com/*": {"GET"},
|
||||
"https://*.spotify.com/api/*": {"GET"},
|
||||
},
|
||||
Permissions: &Permissions{
|
||||
Http: &HTTPPermission{
|
||||
AllowedHosts: []string{"api.example.com", "*.spotify.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
hosts := m.AllowedHosts()
|
||||
Expect(hosts).To(ContainElements("api.example.com", "*.spotify.com"))
|
||||
Expect(hosts).To(Equal([]string{"api.example.com", "*.spotify.com"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("isValidURLPattern", func() {
|
||||
DescribeTable("validates URL patterns",
|
||||
func(pattern string, expected bool) {
|
||||
Expect(isValidURLPattern(pattern)).To(Equal(expected))
|
||||
},
|
||||
Entry("valid HTTPS URL", "https://api.example.com/path", true),
|
||||
Entry("valid HTTP URL", "http://api.example.com/path", true),
|
||||
Entry("URL with wildcard in path", "https://api.example.com/*", true),
|
||||
Entry("URL with wildcard in host", "https://*.example.com/api/*", true),
|
||||
Entry("missing scheme", "api.example.com/path", false),
|
||||
Entry("invalid scheme", "ftp://api.example.com/path", false),
|
||||
Entry("missing host", "https:///path", false),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("extractHost", func() {
|
||||
DescribeTable("extracts hosts from URL patterns",
|
||||
func(pattern string, expected string) {
|
||||
Expect(extractHost(pattern)).To(Equal(expected))
|
||||
},
|
||||
Entry("simple host", "https://api.example.com/path", "api.example.com"),
|
||||
Entry("host with wildcard", "https://*.example.com/api/*", "*.example.com"),
|
||||
Entry("host with port", "https://api.example.com:8080/path", "api.example.com"),
|
||||
Entry("invalid URL", "not-a-url", ""),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user