mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
feat(plugins): allow Plugins to call the Subsonic API (#4260)
* chore: .gitignore any navidrome binary Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement internal authentication handling in middleware Signed-off-by: Deluan <deluan@navidrome.org> * feat(manager): add SubsonicRouter to Manager for API routing Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): add SubsonicAPI Host service for plugins and an example plugin Signed-off-by: Deluan <deluan@navidrome.org> * fix lint Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): refactor path handling in SubsonicAPI to extract endpoint correctly Signed-off-by: Deluan <deluan@navidrome.org> * docs(plugins): add SubsonicAPI service documentation to README Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): implement permission checks for SubsonicAPI service Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): enhance SubsonicAPI service initialization with atomic router handling Signed-off-by: Deluan <deluan@navidrome.org> * refactor(plugins): better encapsulated dependency injection Signed-off-by: Deluan <deluan@navidrome.org> * refactor(plugins): rename parameter in WithInternalAuth for clarity Signed-off-by: Deluan <deluan@navidrome.org> * docs(plugins): update SubsonicAPI permissions section in README for clarity and detail Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): enhance SubsonicAPI permissions output with allowed usernames and admin flag Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): add schema reference to example plugins Signed-off-by: Deluan <deluan@navidrome.org> * remove import alias Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -257,6 +257,18 @@ func displayTypedPermissions(permissions schema.PluginManifestPermissions, inden
|
||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Artwork.Reason)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if permissions.Subsonicapi != nil {
|
||||
allowedUsers := "All Users"
|
||||
if len(permissions.Subsonicapi.AllowedUsernames) > 0 {
|
||||
allowedUsers = strings.Join(permissions.Subsonicapi.AllowedUsernames, ", ")
|
||||
}
|
||||
fmt.Printf("%ssubsonicapi:\n", indent)
|
||||
fmt.Printf("%s Reason: %s\n", indent, permissions.Subsonicapi.Reason)
|
||||
fmt.Printf("%s Allow Admins: %t\n", indent, permissions.Subsonicapi.AllowAdmins)
|
||||
fmt.Printf("%s Allowed Usernames: [%s]\n", indent, allowedUsers)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func displayPluginDetails(manifest *schema.PluginManifest, fileInfo *pluginFileInfo, permInfo *pluginPermissionInfo) {
|
||||
@@ -548,7 +560,7 @@ func pluginRefresh(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("Refreshing plugin '%s'...\n", pluginName)
|
||||
|
||||
// Get the plugin manager and refresh
|
||||
mgr := plugins.GetManager()
|
||||
mgr := GetPluginManager(cmd.Context())
|
||||
log.Debug("Scanning plugins directory", "path", pluginsDir)
|
||||
mgr.ScanPlugins()
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
@@ -193,7 +192,7 @@ func runInitialScan(ctx context.Context) func() error {
|
||||
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||
if scanNeeded {
|
||||
scanner := CreateScanner(ctx)
|
||||
s := CreateScanner(ctx)
|
||||
switch {
|
||||
case fullScanRequired == "1":
|
||||
log.Warn(ctx, "Full scan required after migration")
|
||||
@@ -207,7 +206,7 @@ func runInitialScan(ctx context.Context) func() error {
|
||||
log.Info("Executing initial scan")
|
||||
}
|
||||
|
||||
_, err = scanner.ScanAll(ctx, fullScanRequired == "1")
|
||||
_, err = s.ScanAll(ctx, fullScanRequired == "1")
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scan failed", err)
|
||||
} else {
|
||||
@@ -337,7 +336,7 @@ func startPluginManager(ctx context.Context) func() error {
|
||||
}
|
||||
log.Info(ctx, "Starting plugin manager")
|
||||
// Get the manager instance and scan for plugins
|
||||
manager := plugins.GetManager()
|
||||
manager := GetPluginManager(ctx)
|
||||
manager.ScanPlugins()
|
||||
|
||||
return nil
|
||||
|
||||
@@ -67,7 +67,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
manager := plugins.GetManager()
|
||||
manager := plugins.GetManager(dataStore)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
@@ -92,7 +92,7 @@ func CreatePublicRouter() *public.Router {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
manager := plugins.GetManager()
|
||||
manager := plugins.GetManager(dataStore)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
@@ -137,7 +137,7 @@ func CreateScanner(ctx context.Context) scanner.Scanner {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
manager := plugins.GetManager()
|
||||
manager := plugins.GetManager(dataStore)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
@@ -154,7 +154,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
manager := plugins.GetManager()
|
||||
manager := plugins.GetManager(dataStore)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
@@ -174,6 +174,19 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
return playbackServer
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
manager := plugins.GetManager(dataStore)
|
||||
return manager
|
||||
}
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), metrics.NewPrometheusInstance, db.Db)
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.NewPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||
return manager
|
||||
}
|
||||
|
||||
@@ -40,10 +40,10 @@ var allProviders = wire.NewSet(
|
||||
scanner.New,
|
||||
scanner.NewWatcher,
|
||||
plugins.GetManager,
|
||||
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
||||
metrics.NewPrometheusInstance,
|
||||
db.Db,
|
||||
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
||||
)
|
||||
|
||||
func CreateDataStore() model.DataStore {
|
||||
@@ -117,3 +117,15 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||
return manager
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
Transcoding = contextKey("transcoding")
|
||||
ClientUniqueId = contextKey("clientUniqueId")
|
||||
ReverseProxyIp = contextKey("reverseProxyIp")
|
||||
InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins
|
||||
)
|
||||
|
||||
var allKeys = []contextKey{
|
||||
@@ -62,6 +63,10 @@ func WithReverseProxyIp(ctx context.Context, reverseProxyIp string) context.Cont
|
||||
return context.WithValue(ctx, ReverseProxyIp, reverseProxyIp)
|
||||
}
|
||||
|
||||
func WithInternalAuth(ctx context.Context, username string) context.Context {
|
||||
return context.WithValue(ctx, InternalAuth, username)
|
||||
}
|
||||
|
||||
func UserFrom(ctx context.Context) (model.User, bool) {
|
||||
v, ok := ctx.Value(User).(model.User)
|
||||
return v, ok
|
||||
@@ -102,6 +107,15 @@ func ReverseProxyIpFrom(ctx context.Context) (string, bool) {
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func InternalAuthFrom(ctx context.Context) (string, bool) {
|
||||
if v := ctx.Value(InternalAuth); v != nil {
|
||||
if username, ok := v.(string); ok {
|
||||
return username, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func AddValues(ctx, requestCtx context.Context) context.Context {
|
||||
for _, key := range allKeys {
|
||||
if v := requestCtx.Value(key); v != nil {
|
||||
|
||||
@@ -48,6 +48,7 @@ These services are defined in `plugins/host/` and implemented in corresponding h
|
||||
- WebSocket service (in `plugins/host_websocket.go`) for WebSocket communication
|
||||
- Cache service (in `plugins/host_cache.go`) for TTL-based plugin caching
|
||||
- Artwork service (in `plugins/host_artwork.go`) for generating public artwork URLs
|
||||
- SubsonicAPI service (in `plugins/host_subsonicapi.go`) for accessing Navidrome's Subsonic API
|
||||
|
||||
### Available Host Services
|
||||
|
||||
@@ -292,6 +293,76 @@ _, err = websocket.Close(ctx, &websocket.CloseRequest{
|
||||
})
|
||||
```
|
||||
|
||||
#### SubsonicAPIService
|
||||
|
||||
```protobuf
|
||||
service SubsonicAPIService {
|
||||
rpc Call(CallRequest) returns (CallResponse);
|
||||
}
|
||||
```
|
||||
|
||||
The SubsonicAPIService provides plugins with access to Navidrome's Subsonic API endpoints. This allows plugins to query and interact with Navidrome's music library data using the same API that external Subsonic clients use.
|
||||
|
||||
Key features:
|
||||
|
||||
- **Library Access**: Query artists, albums, tracks, playlists, and other music library data
|
||||
- **Search Functionality**: Search across the music library using various criteria
|
||||
- **Metadata Retrieval**: Get detailed information about music items including ratings, play counts, etc.
|
||||
- **Authentication Handled**: The service automatically handles authentication using internal auth context
|
||||
- **JSON Responses**: All responses are returned as JSON strings for easy parsing
|
||||
|
||||
**Important Security Notes:**
|
||||
|
||||
- Plugins must specify a username via the `u` parameter in the URL - this determines which user's library view and permissions apply
|
||||
- The service uses internal authentication, so plugins don't need to provide passwords or API keys
|
||||
- All Subsonic API security and access controls apply based on the specified user
|
||||
|
||||
Example usage:
|
||||
|
||||
```go
|
||||
// Get ping response to test connectivity
|
||||
resp, err := subsonicAPI.Call(ctx, &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?u=admin",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// resp.Json contains the JSON response
|
||||
|
||||
// Search for artists
|
||||
resp, err = subsonicAPI.Call(ctx, &subsonicapi.CallRequest{
|
||||
Url: "/rest/search3?u=admin&query=Beatles&artistCount=10",
|
||||
})
|
||||
|
||||
// Get album details
|
||||
resp, err = subsonicAPI.Call(ctx, &subsonicapi.CallRequest{
|
||||
Url: "/rest/getAlbum?u=admin&id=123",
|
||||
})
|
||||
|
||||
// Check for errors
|
||||
if resp.Error != "" {
|
||||
// Handle error - could be missing parameters, invalid user, etc.
|
||||
log.Printf("SubsonicAPI error: %s", resp.Error)
|
||||
}
|
||||
```
|
||||
|
||||
**Common URL Patterns:**
|
||||
|
||||
- `/rest/ping?u=USERNAME` - Test API connectivity
|
||||
- `/rest/search3?u=USERNAME&query=TERM` - Search library
|
||||
- `/rest/getArtists?u=USERNAME` - Get all artists
|
||||
- `/rest/getAlbum?u=USERNAME&id=ID` - Get album details
|
||||
- `/rest/getPlaylists?u=USERNAME` - Get user playlists
|
||||
|
||||
**Required Parameters:**
|
||||
|
||||
- `u` (username): Required for all requests - determines user context and permissions
|
||||
- `f=json`: Recommended to get JSON responses (easier to parse than XML)
|
||||
|
||||
The service accepts standard Subsonic API endpoints and parameters. Refer to the [Subsonic API documentation](http://www.subsonic.org/pages/api.jsp) for complete endpoint details, but note that authentication parameters (`p`, `t`, `s`, `c`, `v`) are handled automatically.
|
||||
|
||||
See the [subsonicapi.proto](host/subsonicapi/subsonicapi.proto) file for the full API definition.
|
||||
|
||||
## Plugin Permission System
|
||||
|
||||
Navidrome implements a permission-based security system that controls which host services plugins can access. This system enforces security at load-time by only making authorized services available to plugins in their WebAssembly runtime environment.
|
||||
@@ -329,6 +400,11 @@ Permissions are declared in the plugin's `manifest.json` file using the `permiss
|
||||
},
|
||||
"cache": {
|
||||
"reason": "To cache API responses and reduce rate limiting"
|
||||
},
|
||||
"subsonicapi": {
|
||||
"reason": "To query music library for artist and album information",
|
||||
"allowedUsernames": ["metadata-user"],
|
||||
"allowAdmins": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,6 +416,7 @@ Each permission is represented as a key in the permissions object. The value mus
|
||||
|
||||
- **`http`**: Requires `allowedUrls` object mapping URL patterns to allowed HTTP methods, and optional `allowLocalNetwork` boolean
|
||||
- **`websocket`**: Requires `allowedUrls` array of WebSocket URL patterns, and optional `allowLocalNetwork` boolean
|
||||
- **`subsonicapi`**: Requires `reason` field, with optional `allowedUsernames` array and `allowAdmins` boolean for fine-grained access control
|
||||
- **`config`**, **`cache`**, **`scheduler`**, **`artwork`**: Only require the `reason` field
|
||||
|
||||
**Security Benefits of Required Reasons:**
|
||||
@@ -356,13 +433,14 @@ If no permissions are needed, use an empty permissions object: `"permissions": {
|
||||
The following permission keys correspond to host services:
|
||||
|
||||
| Permission | Host Service | Description | Required Fields |
|
||||
| ----------- | ---------------- | -------------------------------------------------- | ----------------------- |
|
||||
|---------------|--------------------|----------------------------------------------------|-------------------------------------------------------|
|
||||
| `http` | HttpService | Make HTTP requests (GET, POST, PUT, DELETE, etc..) | `reason`, `allowedUrls` |
|
||||
| `websocket` | WebSocketService | Connect to and communicate via WebSockets | `reason`, `allowedUrls` |
|
||||
| `cache` | CacheService | Store and retrieve cached data with TTL | `reason` |
|
||||
| `config` | ConfigService | Access Navidrome configuration values | `reason` |
|
||||
| `scheduler` | SchedulerService | Schedule one-time and recurring tasks | `reason` |
|
||||
| `artwork` | ArtworkService | Generate public URLs for artwork images | `reason` |
|
||||
| `subsonicapi` | SubsonicAPIService | Access Navidrome's Subsonic API endpoints | `reason`, optional: `allowedUsernames`, `allowAdmins` |
|
||||
|
||||
#### HTTP Permission Structure
|
||||
|
||||
@@ -416,6 +494,80 @@ WebSocket permissions require explicit URL whitelisting:
|
||||
- `allowedUrls` (required): Array of WebSocket URL patterns (must start with `ws://` or `wss://`)
|
||||
- `allowLocalNetwork` (optional, default false): Whether to allow connections to localhost/private IPs
|
||||
|
||||
#### SubsonicAPI Permission Structure
|
||||
|
||||
SubsonicAPI permissions control which users plugins can access Navidrome's Subsonic API as, providing fine-grained security controls:
|
||||
|
||||
```json
|
||||
{
|
||||
"subsonicapi": {
|
||||
"reason": "To query music library data for recommendation engine",
|
||||
"allowedUsernames": ["plugin-user", "readonly-user"],
|
||||
"allowAdmins": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `reason` (required): Explanation of why SubsonicAPI access is needed
|
||||
- `allowedUsernames` (optional): Array of specific usernames the plugin is allowed to use. If empty or omitted, any username can be used
|
||||
- `allowAdmins` (optional, default false): Whether the plugin can make API calls using admin user accounts
|
||||
|
||||
**Security Model:**
|
||||
|
||||
The SubsonicAPI service enforces strict user-based access controls:
|
||||
|
||||
- **Username Validation**: The plugin must provide a valid `u` (username) parameter in all API calls
|
||||
- **User Context**: All API responses are filtered based on the specified user's permissions and library access
|
||||
- **Admin Protection**: By default, plugins cannot use admin accounts for API calls to prevent privilege escalation
|
||||
- **Username Restrictions**: When `allowedUsernames` is specified, only those users can be used
|
||||
|
||||
**Common Permission Patterns:**
|
||||
|
||||
```jsonc
|
||||
// Allow any non-admin user (most permissive)
|
||||
{
|
||||
"subsonicapi": {
|
||||
"reason": "To search music library for metadata enhancement",
|
||||
"allowAdmins": false
|
||||
}
|
||||
}
|
||||
|
||||
// Allow only specific users (most secure)
|
||||
{
|
||||
"subsonicapi": {
|
||||
"reason": "To access playlists for synchronization with external service",
|
||||
"allowedUsernames": ["sync-user"],
|
||||
"allowAdmins": false
|
||||
}
|
||||
}
|
||||
|
||||
// Allow admin users (use with caution)
|
||||
{
|
||||
"subsonicapi": {
|
||||
"reason": "To perform administrative tasks like library statistics",
|
||||
"allowAdmins": true
|
||||
}
|
||||
}
|
||||
|
||||
// Restrict to specific users but allow admins
|
||||
{
|
||||
"subsonicapi": {
|
||||
"reason": "To backup playlists for authorized users only",
|
||||
"allowedUsernames": ["backup-admin", "user1", "user2"],
|
||||
"allowAdmins": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
|
||||
- Username matching is case-insensitive
|
||||
- If `allowedUsernames` is empty or omitted, any username can be used (subject to `allowAdmins` setting)
|
||||
- Admin restriction (`allowAdmins: false`) is checked after username validation
|
||||
- Invalid or non-existent usernames will result in API call errors
|
||||
|
||||
### Permission Validation
|
||||
|
||||
The plugin system validates permissions during loading:
|
||||
@@ -581,7 +733,7 @@ func (p *Plugin) GetArtistInfo(ctx context.Context, req *api.ArtistInfoRequest)
|
||||
2. **Verify required fields**: Check that HTTP and WebSocket permissions include `allowedUrls` and other required fields
|
||||
3. **Review logs**: Check for plugin loading errors, manifest validation errors, and WASM runtime errors
|
||||
4. **Test incrementally**: Add permissions one at a time to identify which services your plugin needs
|
||||
5. **Verify service names**: Ensure permission keys match exactly: `http`, `cache`, `config`, `scheduler`, `websocket`, `artwork`
|
||||
5. **Verify service names**: Ensure permission keys match exactly: `http`, `cache`, `config`, `scheduler`, `websocket`, `artwork`, `subsonicapi`
|
||||
6. **Validate manifest**: Use a JSON schema validator to check your manifest against the schema
|
||||
|
||||
### Future Considerations
|
||||
@@ -640,6 +792,7 @@ The protobuf definitions are located in:
|
||||
- `plugins/host/websocket/websocket.proto`: WebSocket service interface
|
||||
- `plugins/host/cache/cache.proto`: Cache service interface
|
||||
- `plugins/host/artwork/artwork.proto`: Artwork service interface
|
||||
- `plugins/host/subsonicapi/subsonicapi.proto`: SubsonicAPI service interface
|
||||
|
||||
### 4. Integration Architecture
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ var _ = Describe("Adapter Media Agent", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Folder = testDataDir
|
||||
|
||||
mgr = createManager()
|
||||
mgr = createManager(nil)
|
||||
mgr.ScanPlugins()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
all: wikimedia coverartarchive crypto-ticker discord-rich-presence
|
||||
all: wikimedia coverartarchive crypto-ticker discord-rich-presence subsonicapi-demo
|
||||
|
||||
wikimedia: wikimedia/plugin.wasm
|
||||
coverartarchive: coverartarchive/plugin.wasm
|
||||
crypto-ticker: crypto-ticker/plugin.wasm
|
||||
discord-rich-presence: discord-rich-presence/plugin.wasm
|
||||
subsonicapi-demo: subsonicapi-demo/plugin.wasm
|
||||
|
||||
wikimedia/plugin.wasm: wikimedia/plugin.go
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./wikimedia
|
||||
@@ -18,5 +19,9 @@ DISCORD_RP_FILES=$(shell find discord-rich-presence -type f -name "*.go")
|
||||
discord-rich-presence/plugin.wasm: $(DISCORD_RP_FILES)
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./discord-rich-presence/...
|
||||
|
||||
subsonicapi-demo/plugin.wasm: subsonicapi-demo/plugin.go
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./subsonicapi-demo
|
||||
|
||||
clean:
|
||||
rm -f wikimedia/plugin.wasm coverartarchive/plugin.wasm crypto-ticker/plugin.wasm discord-rich-presence/plugin.wasm
|
||||
rm -f wikimedia/plugin.wasm coverartarchive/plugin.wasm crypto-ticker/plugin.wasm \
|
||||
discord-rich-presence/plugin.wasm subsonicapi-demo/plugin.wasm
|
||||
@@ -8,6 +8,7 @@ This directory contains example plugins for Navidrome, intended for demonstratio
|
||||
- `coverartarchive/`: Example plugin that retrieves album cover images from the Cover Art Archive.
|
||||
- `crypto-ticker/`: Example plugin using websockets to log real-time cryptocurrency prices.
|
||||
- `discord-rich-presence/`: Example plugin that integrates with Discord Rich Presence to display currently playing tracks on Discord profiles.
|
||||
- `subsonicapi-demo/`: Example plugin that demonstrates how to interact with the Navidrome's Subsonic API from a plugin.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -24,6 +25,7 @@ make wikimedia
|
||||
make coverartarchive
|
||||
make crypto-ticker
|
||||
make discord-rich-presence
|
||||
make subsonicapi-demo
|
||||
```
|
||||
|
||||
This will produce the corresponding `plugin.wasm` files in each plugin's directory.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
|
||||
"name": "coverartarchive",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
|
||||
"name": "discord-rich-presence",
|
||||
"author": "Navidrome Team",
|
||||
"version": "1.0.0",
|
||||
|
||||
88
plugins/examples/subsonicapi-demo/README.md
Normal file
88
plugins/examples/subsonicapi-demo/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# SubsonicAPI Demo Plugin
|
||||
|
||||
This example plugin demonstrates how to use the SubsonicAPI host service to access Navidrome's Subsonic API from within a plugin.
|
||||
|
||||
## What it does
|
||||
|
||||
The plugin performs the following operations during initialization:
|
||||
|
||||
1. **Ping the server**: Calls `/rest/ping` to check if the Subsonic API is responding
|
||||
2. **Get license info**: Calls `/rest/getLicense` to retrieve server license information
|
||||
|
||||
## Key Features
|
||||
|
||||
- Shows how to request `subsonicapi` permission in the manifest
|
||||
- Demonstrates making Subsonic API calls using the `subsonicapi.Call()` method
|
||||
- Handles both successful responses and errors
|
||||
- Uses proper lifecycle management with `OnInit`
|
||||
|
||||
## Usage
|
||||
|
||||
### Manifest Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"subsonicapi": {
|
||||
"reason": "Demonstrate accessing Navidrome's Subsonic API from within plugins",
|
||||
"allowAdmins": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin Implementation
|
||||
|
||||
```go
|
||||
import "github.com/navidrome/navidrome/plugins/host/subsonicapi"
|
||||
|
||||
var subsonicService = subsonicapi.NewSubsonicAPIService()
|
||||
|
||||
// OnInit is called when the plugin is loaded
|
||||
func (SubsonicAPIDemoPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) {
|
||||
// Make API calls
|
||||
response, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?u=admin",
|
||||
})
|
||||
// Handle response...
|
||||
}
|
||||
```
|
||||
|
||||
When running Navidrome with this plugin installed, it will automatically call the Subsonic API endpoints during the
|
||||
server startup, and you can see the results in the logs:
|
||||
|
||||
```agsl
|
||||
INFO[0000] 2022/01/01 00:00:00 SubsonicAPI Demo Plugin initializing...
|
||||
DEBU[0000] API: New request /ping client=subsonicapi-demo username=admin version=1.16.1
|
||||
DEBU[0000] API: Successful response endpoint=/ping status=OK
|
||||
DEBU[0000] API: New request /getLicense client=subsonicapi-demo username=admin version=1.16.1
|
||||
INFO[0000] 2022/01/01 00:00:00 SubsonicAPI ping response: {"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"dev","openSubsonic":true}}
|
||||
DEBU[0000] API: Successful response endpoint=/getLicense status=OK
|
||||
DEBU[0000] Plugin initialized successfully elapsed=41.9ms plugin=subsonicapi-demo
|
||||
INFO[0000] 2022/01/01 00:00:00 SubsonicAPI license info: {"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"dev","openSubsonic":true,"license":{"valid":true}}}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Authentication**: The plugin must provide valid authentication parameters in the URL:
|
||||
- **Required**: `u` (username) - The service validates this parameter is present
|
||||
- Example: `"/rest/ping?u=admin"`
|
||||
2. **URL Format**: Only the path and query parameters from the URL are used - host, protocol, and method are ignored
|
||||
3. **Automatic Parameters**: The service automatically adds:
|
||||
- `c`: Plugin name (client identifier)
|
||||
- `v`: Subsonic API version (1.16.1)
|
||||
- `f`: Response format (json)
|
||||
4. **Internal Authentication**: The service sets up internal authentication using the `u` parameter
|
||||
5. **Lifecycle**: This plugin uses `LifecycleManagement` with only the `OnInit` method
|
||||
|
||||
## Building
|
||||
|
||||
This plugin uses the `wasip1` build constraint and must be compiled for WebAssembly:
|
||||
|
||||
```bash
|
||||
# Using the project's make target (recommended)
|
||||
make plugin-examples
|
||||
|
||||
# Manual compilation (when using the proper toolchain)
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go
|
||||
```
|
||||
16
plugins/examples/subsonicapi-demo/manifest.json
Normal file
16
plugins/examples/subsonicapi-demo/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
|
||||
"name": "subsonicapi-demo",
|
||||
"author": "Navidrome Team",
|
||||
"version": "1.0.0",
|
||||
"description": "Example plugin demonstrating SubsonicAPI host service usage",
|
||||
"website": "https://github.com/navidrome/navidrome",
|
||||
"capabilities": ["LifecycleManagement"],
|
||||
"permissions": {
|
||||
"subsonicapi": {
|
||||
"reason": "Demonstrate accessing Navidrome's Subsonic API from within plugins",
|
||||
"allowAdmins": true,
|
||||
"allowedUsernames": ["admin"]
|
||||
}
|
||||
}
|
||||
}
|
||||
64
plugins/examples/subsonicapi-demo/plugin.go
Normal file
64
plugins/examples/subsonicapi-demo/plugin.go
Normal file
@@ -0,0 +1,64 @@
|
||||
//go:build wasip1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/host/subsonicapi"
|
||||
)
|
||||
|
||||
// SubsonicAPIService instance for making API calls
|
||||
var subsonicService = subsonicapi.NewSubsonicAPIService()
|
||||
|
||||
// SubsonicAPIDemoPlugin implements LifecycleManagement interface
|
||||
type SubsonicAPIDemoPlugin struct{}
|
||||
|
||||
// OnInit is called when the plugin is loaded
|
||||
func (SubsonicAPIDemoPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) {
|
||||
log.Printf("SubsonicAPI Demo Plugin initializing...")
|
||||
|
||||
// Example: Call the ping endpoint to check if the server is alive
|
||||
response, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?u=admin",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("SubsonicAPI call failed: %v", err)
|
||||
return &api.InitResponse{Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
if response.Error != "" {
|
||||
log.Printf("SubsonicAPI returned error: %s", response.Error)
|
||||
return &api.InitResponse{Error: response.Error}, nil
|
||||
}
|
||||
|
||||
log.Printf("SubsonicAPI ping response: %s", response.Json)
|
||||
|
||||
// Example: Get server info
|
||||
infoResponse, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{
|
||||
Url: "/rest/getLicense?u=admin",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("SubsonicAPI getLicense call failed: %v", err)
|
||||
return &api.InitResponse{Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
if infoResponse.Error != "" {
|
||||
log.Printf("SubsonicAPI getLicense returned error: %s", infoResponse.Error)
|
||||
return &api.InitResponse{Error: infoResponse.Error}, nil
|
||||
}
|
||||
|
||||
log.Printf("SubsonicAPI license info: %s", infoResponse.Json)
|
||||
|
||||
return &api.InitResponse{}, nil
|
||||
}
|
||||
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
api.RegisterLifecycleManagement(&SubsonicAPIDemoPlugin{})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
|
||||
"name": "wikimedia",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
|
||||
71
plugins/host/subsonicapi/subsonicapi.pb.go
Normal file
71
plugins/host/subsonicapi/subsonicapi.pb.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/subsonicapi/subsonicapi.proto
|
||||
|
||||
package subsonicapi
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type CallRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
|
||||
}
|
||||
|
||||
func (x *CallRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *CallRequest) GetUrl() string {
|
||||
if x != nil {
|
||||
return x.Url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CallResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Json string `protobuf:"bytes,1,opt,name=json,proto3" json:"json,omitempty"`
|
||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // Non-empty if operation failed
|
||||
}
|
||||
|
||||
func (x *CallResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *CallResponse) GetJson() string {
|
||||
if x != nil {
|
||||
return x.Json
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CallResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type SubsonicAPIService interface {
|
||||
Call(context.Context, *CallRequest) (*CallResponse, error)
|
||||
}
|
||||
19
plugins/host/subsonicapi/subsonicapi.proto
Normal file
19
plugins/host/subsonicapi/subsonicapi.proto
Normal file
@@ -0,0 +1,19 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package subsonicapi;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/subsonicapi;subsonicapi";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service SubsonicAPIService {
|
||||
rpc Call(CallRequest) returns (CallResponse);
|
||||
}
|
||||
|
||||
message CallRequest {
|
||||
string url = 1;
|
||||
}
|
||||
|
||||
message CallResponse {
|
||||
string json = 1;
|
||||
string error = 2; // Non-empty if operation failed
|
||||
}
|
||||
66
plugins/host/subsonicapi/subsonicapi_host.pb.go
Normal file
66
plugins/host/subsonicapi/subsonicapi_host.pb.go
Normal file
@@ -0,0 +1,66 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/subsonicapi/subsonicapi.proto
|
||||
|
||||
package subsonicapi
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
api "github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const (
|
||||
i32 = api.ValueTypeI32
|
||||
i64 = api.ValueTypeI64
|
||||
)
|
||||
|
||||
type _subsonicAPIService struct {
|
||||
SubsonicAPIService
|
||||
}
|
||||
|
||||
// Instantiate a Go-defined module named "env" that exports host functions.
|
||||
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SubsonicAPIService) error {
|
||||
envBuilder := r.NewHostModuleBuilder("env")
|
||||
h := _subsonicAPIService{hostFunctions}
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Call), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("call")
|
||||
|
||||
_, err := envBuilder.Instantiate(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h _subsonicAPIService) _Call(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(CallRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Call(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
44
plugins/host/subsonicapi/subsonicapi_plugin.pb.go
Normal file
44
plugins/host/subsonicapi/subsonicapi_plugin.pb.go
Normal file
@@ -0,0 +1,44 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/subsonicapi/subsonicapi.proto
|
||||
|
||||
package subsonicapi
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type subsonicAPIService struct{}
|
||||
|
||||
func NewSubsonicAPIService() SubsonicAPIService {
|
||||
return subsonicAPIService{}
|
||||
}
|
||||
|
||||
//go:wasmimport env call
|
||||
func _call(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h subsonicAPIService) Call(ctx context.Context, request *CallRequest) (*CallResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _call(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(CallResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
441
plugins/host/subsonicapi/subsonicapi_vtproto.pb.go
Normal file
441
plugins/host/subsonicapi/subsonicapi_vtproto.pb.go
Normal file
@@ -0,0 +1,441 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/subsonicapi/subsonicapi.proto
|
||||
|
||||
package subsonicapi
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
bits "math/bits"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
func (m *CallRequest) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *CallRequest) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *CallRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if len(m.Url) > 0 {
|
||||
i -= len(m.Url)
|
||||
copy(dAtA[i:], m.Url)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Url)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func (m *CallResponse) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *CallResponse) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *CallResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if len(m.Error) > 0 {
|
||||
i -= len(m.Error)
|
||||
copy(dAtA[i:], m.Error)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Error)))
|
||||
i--
|
||||
dAtA[i] = 0x12
|
||||
}
|
||||
if len(m.Json) > 0 {
|
||||
i -= len(m.Json)
|
||||
copy(dAtA[i:], m.Json)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Json)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func encodeVarint(dAtA []byte, offset int, v uint64) int {
|
||||
offset -= sov(v)
|
||||
base := offset
|
||||
for v >= 1<<7 {
|
||||
dAtA[offset] = uint8(v&0x7f | 0x80)
|
||||
v >>= 7
|
||||
offset++
|
||||
}
|
||||
dAtA[offset] = uint8(v)
|
||||
return base
|
||||
}
|
||||
func (m *CallRequest) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
l = len(m.Url)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func (m *CallResponse) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
l = len(m.Json)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
l = len(m.Error)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func sov(x uint64) (n int) {
|
||||
return (bits.Len64(x|1) + 6) / 7
|
||||
}
|
||||
func soz(x uint64) (n int) {
|
||||
return sov(uint64((x << 1) ^ uint64((int64(x) >> 63))))
|
||||
}
|
||||
func (m *CallRequest) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: CallRequest: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: CallRequest: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType)
|
||||
}
|
||||
var stringLen uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLen |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLen := int(stringLen)
|
||||
if intStringLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + intStringLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Url = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *CallResponse) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: CallResponse: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: CallResponse: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Json", wireType)
|
||||
}
|
||||
var stringLen uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLen |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLen := int(stringLen)
|
||||
if intStringLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + intStringLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Json = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
case 2:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType)
|
||||
}
|
||||
var stringLen uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLen |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLen := int(stringLen)
|
||||
if intStringLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + intStringLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Error = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skip(dAtA []byte) (n int, err error) {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
depth := 0
|
||||
for iNdEx < l {
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= (uint64(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
wireType := int(wire & 0x7)
|
||||
switch wireType {
|
||||
case 0:
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx++
|
||||
if dAtA[iNdEx-1] < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
iNdEx += 8
|
||||
case 2:
|
||||
var length int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
length |= (int(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if length < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
iNdEx += length
|
||||
case 3:
|
||||
depth++
|
||||
case 4:
|
||||
if depth == 0 {
|
||||
return 0, ErrUnexpectedEndOfGroup
|
||||
}
|
||||
depth--
|
||||
case 5:
|
||||
iNdEx += 4
|
||||
default:
|
||||
return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
|
||||
}
|
||||
if iNdEx < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
if depth == 0 {
|
||||
return iNdEx, nil
|
||||
}
|
||||
}
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling")
|
||||
ErrIntOverflow = fmt.Errorf("proto: integer overflow")
|
||||
ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group")
|
||||
)
|
||||
@@ -16,7 +16,7 @@ var _ = Describe("SchedulerService", func() {
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
manager = createManager()
|
||||
manager = createManager(nil)
|
||||
ss = manager.schedulerService
|
||||
})
|
||||
|
||||
|
||||
166
plugins/host_subsonicapi.go
Normal file
166
plugins/host_subsonicapi.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/host/subsonicapi"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
)
|
||||
|
||||
// SubsonicAPIService is the interface for the Subsonic API service
|
||||
//
|
||||
// Authentication: The plugin must provide valid authentication parameters in the URL:
|
||||
// - Required: `u` (username) - The service validates this parameter is present
|
||||
// - Example: `"/rest/ping?u=admin"`
|
||||
//
|
||||
// URL Format: Only the path and query parameters from the URL are used - host, protocol, and method are ignored
|
||||
//
|
||||
// Automatic Parameters: The service automatically adds:
|
||||
// - `c`: Plugin name (client identifier)
|
||||
// - `v`: Subsonic API version (1.16.1)
|
||||
// - `f`: Response format (json)
|
||||
//
|
||||
// See example usage in the `plugins/examples/subsonicapi-demo` plugin
|
||||
type subsonicAPIServiceImpl struct {
|
||||
pluginID string
|
||||
router SubsonicRouter
|
||||
ds model.DataStore
|
||||
permissions *subsonicAPIPermissions
|
||||
}
|
||||
|
||||
func newSubsonicAPIService(pluginID string, router *SubsonicRouter, ds model.DataStore, permissions *schema.PluginManifestPermissionsSubsonicapi) subsonicapi.SubsonicAPIService {
|
||||
return &subsonicAPIServiceImpl{
|
||||
pluginID: pluginID,
|
||||
router: *router,
|
||||
ds: ds,
|
||||
permissions: parseSubsonicAPIPermissions(permissions),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *subsonicAPIServiceImpl) Call(ctx context.Context, req *subsonicapi.CallRequest) (*subsonicapi.CallResponse, error) {
|
||||
if s.router == nil {
|
||||
return &subsonicapi.CallResponse{
|
||||
Error: "SubsonicAPI router not available",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Parse the input URL
|
||||
parsedURL, err := url.Parse(req.Url)
|
||||
if err != nil {
|
||||
return &subsonicapi.CallResponse{
|
||||
Error: fmt.Sprintf("invalid URL format: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Extract query parameters
|
||||
query := parsedURL.Query()
|
||||
|
||||
// Validate that 'u' (username) parameter is present
|
||||
username := query.Get("u")
|
||||
if username == "" {
|
||||
return &subsonicapi.CallResponse{
|
||||
Error: "missing required parameter 'u' (username)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := s.checkPermissions(ctx, username); err != nil {
|
||||
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err)
|
||||
return &subsonicapi.CallResponse{Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
// Add required Subsonic API parameters
|
||||
query.Set("c", s.pluginID) // Client name (plugin ID)
|
||||
query.Set("f", "json") // Response format
|
||||
query.Set("v", subsonic.Version) // API version
|
||||
|
||||
// Extract the endpoint from the path
|
||||
endpoint := path.Base(parsedURL.Path)
|
||||
|
||||
// Build the final URL with processed path and modified query parameters
|
||||
finalURL := &url.URL{
|
||||
Path: "/" + endpoint,
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
|
||||
// Create HTTP request with internal authentication
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", finalURL.String(), nil)
|
||||
if err != nil {
|
||||
return &subsonicapi.CallResponse{
|
||||
Error: fmt.Sprintf("failed to create HTTP request: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Set internal authentication context using the username from the 'u' parameter
|
||||
authCtx := request.WithInternalAuth(httpReq.Context(), username)
|
||||
httpReq = httpReq.WithContext(authCtx)
|
||||
|
||||
// Use ResponseRecorder to capture the response
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
// Call the subsonic router
|
||||
s.router.ServeHTTP(recorder, httpReq)
|
||||
|
||||
// Return the response body as JSON
|
||||
return &subsonicapi.CallResponse{
|
||||
Json: recorder.Body.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
|
||||
if s.permissions == nil {
|
||||
return nil
|
||||
}
|
||||
if len(s.permissions.AllowedUsernames) > 0 {
|
||||
if _, ok := s.permissions.usernameMap[strings.ToLower(username)]; !ok {
|
||||
return fmt.Errorf("username %s is not allowed", username)
|
||||
}
|
||||
}
|
||||
if !s.permissions.AllowAdmins {
|
||||
if s.router == nil {
|
||||
return fmt.Errorf("permissions check failed: router not available")
|
||||
}
|
||||
usr, err := s.ds.User(ctx).FindByUsername(username)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return fmt.Errorf("username %s not found", username)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if usr.IsAdmin {
|
||||
return fmt.Errorf("calling SubsonicAPI as admin user is not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type subsonicAPIPermissions struct {
|
||||
AllowedUsernames []string
|
||||
AllowAdmins bool
|
||||
usernameMap map[string]struct{}
|
||||
}
|
||||
|
||||
func parseSubsonicAPIPermissions(data *schema.PluginManifestPermissionsSubsonicapi) *subsonicAPIPermissions {
|
||||
if data == nil {
|
||||
return &subsonicAPIPermissions{}
|
||||
}
|
||||
perms := &subsonicAPIPermissions{
|
||||
AllowedUsernames: data.AllowedUsernames,
|
||||
AllowAdmins: data.AllowAdmins,
|
||||
usernameMap: make(map[string]struct{}),
|
||||
}
|
||||
for _, u := range data.AllowedUsernames {
|
||||
perms.usernameMap[strings.ToLower(u)] = struct{}{}
|
||||
}
|
||||
return perms
|
||||
}
|
||||
218
plugins/host_subsonicapi_test.go
Normal file
218
plugins/host_subsonicapi_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/host/subsonicapi"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("SubsonicAPI Host Service", func() {
|
||||
var (
|
||||
service *subsonicAPIServiceImpl
|
||||
mockRouter http.Handler
|
||||
userRepo *tests.MockedUserRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// Setup mock datastore with users
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
_ = userRepo.Put(&model.User{UserName: "admin", IsAdmin: true})
|
||||
_ = userRepo.Put(&model.User{UserName: "user", IsAdmin: false})
|
||||
ds := &tests.MockDataStore{MockedUser: userRepo}
|
||||
|
||||
// Create a mock router
|
||||
mockRouter = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"subsonic-response":{"status":"ok","version":"1.16.1"}}`))
|
||||
})
|
||||
|
||||
// Create service implementation
|
||||
service = &subsonicAPIServiceImpl{
|
||||
pluginID: "test-plugin",
|
||||
router: mockRouter,
|
||||
ds: ds,
|
||||
}
|
||||
})
|
||||
|
||||
// Helper function to create a mock router that captures the request
|
||||
setupRequestCapture := func() **http.Request {
|
||||
var capturedRequest *http.Request
|
||||
mockRouter = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedRequest = r
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
})
|
||||
service.router = mockRouter
|
||||
return &capturedRequest
|
||||
}
|
||||
|
||||
Describe("Call", func() {
|
||||
Context("when subsonic router is available", func() {
|
||||
It("should process the request successfully", func() {
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?u=admin",
|
||||
}
|
||||
|
||||
resp, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Error).To(BeEmpty())
|
||||
Expect(resp.Json).To(ContainSubstring("subsonic-response"))
|
||||
Expect(resp.Json).To(ContainSubstring("ok"))
|
||||
})
|
||||
|
||||
It("should add required parameters to the URL", func() {
|
||||
capturedRequestPtr := setupRequestCapture()
|
||||
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "/rest/getAlbum.view?id=123&u=admin",
|
||||
}
|
||||
|
||||
_, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*capturedRequestPtr).ToNot(BeNil())
|
||||
|
||||
query := (*capturedRequestPtr).URL.Query()
|
||||
Expect(query.Get("c")).To(Equal("test-plugin"))
|
||||
Expect(query.Get("f")).To(Equal("json"))
|
||||
Expect(query.Get("v")).To(Equal("1.16.1"))
|
||||
Expect(query.Get("id")).To(Equal("123"))
|
||||
Expect(query.Get("u")).To(Equal("admin"))
|
||||
})
|
||||
|
||||
It("should only use path and query from the input URL", func() {
|
||||
capturedRequestPtr := setupRequestCapture()
|
||||
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "https://external.example.com:8080/rest/ping?u=admin",
|
||||
}
|
||||
|
||||
_, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*capturedRequestPtr).ToNot(BeNil())
|
||||
Expect((*capturedRequestPtr).URL.Path).To(Equal("/ping"))
|
||||
Expect((*capturedRequestPtr).URL.Host).To(BeEmpty())
|
||||
Expect((*capturedRequestPtr).URL.Scheme).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("ignores the path prefix in the URL", func() {
|
||||
capturedRequestPtr := setupRequestCapture()
|
||||
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "/basepath/rest/ping?u=admin",
|
||||
}
|
||||
|
||||
_, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*capturedRequestPtr).ToNot(BeNil())
|
||||
Expect((*capturedRequestPtr).URL.Path).To(Equal("/ping"))
|
||||
})
|
||||
|
||||
It("should set internal authentication with username from 'u' parameter", func() {
|
||||
capturedRequestPtr := setupRequestCapture()
|
||||
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?u=testuser",
|
||||
}
|
||||
|
||||
_, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*capturedRequestPtr).ToNot(BeNil())
|
||||
|
||||
// Verify that internal authentication is set in the context
|
||||
username, ok := request.InternalAuthFrom((*capturedRequestPtr).Context())
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(username).To(Equal("testuser"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when subsonic router is not available", func() {
|
||||
BeforeEach(func() {
|
||||
service.router = nil
|
||||
})
|
||||
|
||||
It("should return an error", func() {
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?u=admin",
|
||||
}
|
||||
|
||||
resp, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Error).To(Equal("SubsonicAPI router not available"))
|
||||
Expect(resp.Json).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when URL is invalid", func() {
|
||||
It("should return an error for malformed URLs", func() {
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "://invalid-url",
|
||||
}
|
||||
|
||||
resp, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Error).To(ContainSubstring("invalid URL format"))
|
||||
Expect(resp.Json).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return an error when 'u' parameter is missing", func() {
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?p=password",
|
||||
}
|
||||
|
||||
resp, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Error).To(Equal("missing required parameter 'u' (username)"))
|
||||
Expect(resp.Json).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("permission checks", func() {
|
||||
It("rejects disallowed username", func() {
|
||||
service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{
|
||||
Reason: "test",
|
||||
AllowedUsernames: []string{"user"},
|
||||
})
|
||||
|
||||
resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Error).To(ContainSubstring("not allowed"))
|
||||
})
|
||||
|
||||
It("rejects admin when allowAdmins is false", func() {
|
||||
service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{Reason: "test"})
|
||||
|
||||
resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Error).To(ContainSubstring("not allowed"))
|
||||
})
|
||||
|
||||
It("allows admin when allowAdmins is true", func() {
|
||||
service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{Reason: "test", AllowAdmins: true})
|
||||
|
||||
resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Error).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,7 +84,7 @@ var _ = Describe("WebSocket Host Service", func() {
|
||||
DeferCleanup(server.Close)
|
||||
|
||||
// Create a new manager and websocket service
|
||||
manager = createManager()
|
||||
manager = createManager(nil)
|
||||
wsService = newWebsocketService(manager)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,18 +7,22 @@ package plugins
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/scheduler/scheduler.proto
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/cache/cache.proto
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/artwork/artwork.proto
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/subsonicapi/subsonicapi.proto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
@@ -79,28 +83,33 @@ func (p *plugin) waitForCompilation() error {
|
||||
return p.compilationErr
|
||||
}
|
||||
|
||||
type SubsonicRouter http.Handler
|
||||
|
||||
// Manager is a singleton that manages plugins
|
||||
type Manager struct {
|
||||
plugins map[string]*plugin // Map of plugin folder name to plugin info
|
||||
mu sync.RWMutex // Protects plugins map
|
||||
subsonicRouter atomic.Pointer[SubsonicRouter] // Subsonic API router
|
||||
schedulerService *schedulerService // Service for handling scheduled tasks
|
||||
websocketService *websocketService // Service for handling WebSocket connections
|
||||
lifecycle *pluginLifecycleManager // Manages plugin lifecycle and initialization
|
||||
adapters map[string]WasmPlugin // Map of plugin folder name + capability to adapter
|
||||
ds model.DataStore // DataStore for accessing persistent data
|
||||
}
|
||||
|
||||
// GetManager returns the singleton instance of Manager
|
||||
func GetManager() *Manager {
|
||||
func GetManager(ds model.DataStore) *Manager {
|
||||
return singleton.GetInstance(func() *Manager {
|
||||
return createManager()
|
||||
return createManager(ds)
|
||||
})
|
||||
}
|
||||
|
||||
// createManager creates a new Manager instance. Used in tests
|
||||
func createManager() *Manager {
|
||||
func createManager(ds model.DataStore) *Manager {
|
||||
m := &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
lifecycle: newPluginLifecycleManager(),
|
||||
ds: ds,
|
||||
}
|
||||
|
||||
// Create the host services
|
||||
@@ -110,6 +119,11 @@ func createManager() *Manager {
|
||||
return m
|
||||
}
|
||||
|
||||
// SetSubsonicRouter sets the SubsonicRouter after Manager initialization
|
||||
func (m *Manager) SetSubsonicRouter(router SubsonicRouter) {
|
||||
m.subsonicRouter.Store(&router)
|
||||
}
|
||||
|
||||
// registerPlugin adds a plugin to the registry with the given parameters
|
||||
// Used internally by ScanPlugins to register plugins
|
||||
func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest *schema.PluginManifest) *plugin {
|
||||
|
||||
@@ -27,7 +27,7 @@ var _ = Describe("Plugin Manager", func() {
|
||||
conf.Server.Plugins.Folder = testDataDir
|
||||
|
||||
ctx = GinkgoT().Context()
|
||||
mgr = createManager()
|
||||
mgr = createManager(nil)
|
||||
mgr.ScanPlugins()
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ var _ = Describe("Plugin Manager", func() {
|
||||
})
|
||||
|
||||
conf.Server.Plugins.Folder = tempPluginsDir
|
||||
m = createManager()
|
||||
m = createManager(nil)
|
||||
})
|
||||
|
||||
// Helper to create a complete valid plugin for manager testing
|
||||
|
||||
@@ -55,7 +55,7 @@ var _ = Describe("Plugin Permissions", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
mgr = createManager()
|
||||
mgr = createManager(nil)
|
||||
tempDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/navidrome/navidrome/plugins/host/config"
|
||||
"github.com/navidrome/navidrome/plugins/host/http"
|
||||
"github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
"github.com/navidrome/navidrome/plugins/host/subsonicapi"
|
||||
"github.com/navidrome/navidrome/plugins/host/websocket"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/tetratelabs/wazero"
|
||||
@@ -132,6 +133,14 @@ func (m *Manager) setupHostServices(ctx context.Context, r wazero.Runtime, plugi
|
||||
}
|
||||
return loadHostLibrary[websocket.WebSocketService](ctx, websocket.Instantiate, m.websocketService.HostFunctions(pluginID, wsPerms))
|
||||
}},
|
||||
{"subsonicapi", permissions.Subsonicapi != nil, func() (map[string]wazeroapi.FunctionDefinition, error) {
|
||||
if router := m.subsonicRouter.Load(); router != nil {
|
||||
service := newSubsonicAPIService(pluginID, m.subsonicRouter.Load(), m.ds, permissions.Subsonicapi)
|
||||
return loadHostLibrary[subsonicapi.SubsonicAPIService](ctx, subsonicapi.Instantiate, service)
|
||||
}
|
||||
log.Error(ctx, "SubsonicAPI service requested but router not available", "plugin", pluginID)
|
||||
return nil, fmt.Errorf("SubsonicAPI router not available for plugin %s", pluginID)
|
||||
}},
|
||||
}
|
||||
|
||||
// Load only permitted services
|
||||
|
||||
@@ -40,7 +40,7 @@ var _ = Describe("CachingRuntime", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
mgr = createManager()
|
||||
mgr = createManager(nil)
|
||||
// Add permissions for the test plugin using typed struct
|
||||
permissions := schema.PluginManifestPermissions{
|
||||
Http: &schema.PluginManifestPermissionsHttp{
|
||||
|
||||
@@ -157,6 +157,27 @@
|
||||
"description": "Artwork service permissions"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subsonicapi": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/$defs/basePermission" },
|
||||
{
|
||||
"type": "object",
|
||||
"description": "SubsonicAPI service permissions",
|
||||
"properties": {
|
||||
"allowedUsernames": {
|
||||
"type": "array",
|
||||
"description": "List of usernames the plugin can pass as u. Any user if empty",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"allowAdmins": {
|
||||
"type": "boolean",
|
||||
"description": "If false, reject calls where the u is an admin",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ type PluginManifestPermissions struct {
|
||||
// Scheduler corresponds to the JSON schema field "scheduler".
|
||||
Scheduler *PluginManifestPermissionsScheduler `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"`
|
||||
|
||||
// Subsonicapi corresponds to the JSON schema field "subsonicapi".
|
||||
Subsonicapi *PluginManifestPermissionsSubsonicapi `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"`
|
||||
|
||||
// Websocket corresponds to the JSON schema field "websocket".
|
||||
Websocket *PluginManifestPermissionsWebsocket `json:"websocket,omitempty" yaml:"websocket,omitempty" mapstructure:"websocket,omitempty"`
|
||||
|
||||
@@ -305,6 +308,42 @@ func (j *PluginManifestPermissionsScheduler) UnmarshalJSON(value []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubsonicAPI service permissions
|
||||
type PluginManifestPermissionsSubsonicapi struct {
|
||||
// If false, reject calls where the u is an admin
|
||||
AllowAdmins bool `json:"allowAdmins,omitempty" yaml:"allowAdmins,omitempty" mapstructure:"allowAdmins,omitempty"`
|
||||
|
||||
// List of usernames the plugin can pass as u. Any user if empty
|
||||
AllowedUsernames []string `json:"allowedUsernames,omitempty" yaml:"allowedUsernames,omitempty" mapstructure:"allowedUsernames,omitempty"`
|
||||
|
||||
// Explanation of why this permission is needed
|
||||
Reason string `json:"reason" yaml:"reason" mapstructure:"reason"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (j *PluginManifestPermissionsSubsonicapi) UnmarshalJSON(value []byte) error {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(value, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := raw["reason"]; raw != nil && !ok {
|
||||
return fmt.Errorf("field reason in PluginManifestPermissionsSubsonicapi: required")
|
||||
}
|
||||
type Plain PluginManifestPermissionsSubsonicapi
|
||||
var plain Plain
|
||||
if err := json.Unmarshal(value, &plain); err != nil {
|
||||
return err
|
||||
}
|
||||
if v, ok := raw["allowAdmins"]; !ok || v == nil {
|
||||
plain.AllowAdmins = false
|
||||
}
|
||||
if len(plain.Reason) < 1 {
|
||||
return fmt.Errorf("field %s length: must be >= %d", "reason", 1)
|
||||
}
|
||||
*j = PluginManifestPermissionsSubsonicapi(plain)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WebSocket service permissions
|
||||
type PluginManifestPermissionsWebsocket struct {
|
||||
// Whether to allow connections to local/private network addresses
|
||||
|
||||
@@ -214,6 +214,15 @@ func UsernameFromReverseProxyHeader(r *http.Request) string {
|
||||
return username
|
||||
}
|
||||
|
||||
func InternalAuth(r *http.Request) string {
|
||||
username, ok := request.InternalAuthFrom(r.Context())
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
log.Trace(r, "Found username in InternalAuth", "username", username)
|
||||
return username
|
||||
}
|
||||
|
||||
func UsernameFromConfig(*http.Request) string {
|
||||
return conf.Server.DevAutoLoginUsername
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
@@ -46,9 +47,9 @@ func postFormToQueryParams(next http.Handler) http.Handler {
|
||||
func checkRequiredParameters(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var requiredParameters []string
|
||||
var username string
|
||||
|
||||
if username = server.UsernameFromReverseProxyHeader(r); username != "" {
|
||||
username := cmp.Or(server.InternalAuth(r), server.UsernameFromReverseProxyHeader(r))
|
||||
if username != "" {
|
||||
requiredParameters = []string{"v", "c"}
|
||||
} else {
|
||||
requiredParameters = []string{"u", "v", "c"}
|
||||
@@ -87,16 +88,19 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
var usr *model.User
|
||||
var err error
|
||||
|
||||
if username := server.UsernameFromReverseProxyHeader(r); username != "" {
|
||||
internalAuth := server.InternalAuth(r)
|
||||
proxyAuth := server.UsernameFromReverseProxyHeader(r)
|
||||
if username := cmp.Or(internalAuth, proxyAuth); username != "" {
|
||||
authType := If(internalAuth != "", "internal", "reverse-proxy")
|
||||
usr, err = ds.User(ctx).FindByUsername(username)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "reverse-proxy", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "API: Invalid login", "auth", "reverse-proxy", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
log.Warn(ctx, "API: Invalid login", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
} else if err != nil {
|
||||
log.Error(ctx, "API: Error authenticating username", "auth", "reverse-proxy", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
}
|
||||
} else {
|
||||
p := req.Params(r)
|
||||
|
||||
@@ -281,6 +281,31 @@ var _ = Describe("Middlewares", func() {
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
When("using internal authentication", func() {
|
||||
It("passes authentication with correct internal credentials", func() {
|
||||
// Simulate internal authentication by setting the context with WithInternalAuth
|
||||
r := newGetRequest()
|
||||
r = r.WithContext(request.WithInternalAuth(r.Context(), "admin"))
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
user, _ := request.UserFrom(next.req.Context())
|
||||
Expect(user.UserName).To(Equal("admin"))
|
||||
})
|
||||
|
||||
It("fails authentication with missing internal context", func() {
|
||||
r := newGetRequest("u=admin")
|
||||
// Do not set the internal auth context
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
// Internal auth requires the context, so this should fail
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetPlayer", func() {
|
||||
|
||||
Reference in New Issue
Block a user