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:
Deluan Quintão
2025-06-25 14:18:32 -04:00
committed by GitHub
parent 024b50dc2b
commit 45c408a674
34 changed files with 1573 additions and 46 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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()
})

View File

@@ -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

View File

@@ -6,8 +6,9 @@ This directory contains example plugins for Navidrome, intended for demonstratio
- `wikimedia/`: Example plugin that retrieves artist information from Wikidata.
- `coverartarchive/`: Example plugin that retrieves album cover images from the Cover Art Archive.
- `crypto-ticker/`: Example plugin using websockets to log real-time crypto currency prices.
- `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.

View File

@@ -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",

View File

@@ -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",

View 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
```

View 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"]
}
}
}

View 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{})
}

View File

@@ -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",

View 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)
}

View 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
}

View 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
}

View 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
}

View 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")
)

View File

@@ -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
View 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
}

View 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())
})
})
})
})

View File

@@ -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)
})

View File

@@ -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 {

View File

@@ -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

View File

@@ -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()
})

View File

@@ -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

View File

@@ -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{

View File

@@ -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
}
}
}
]
}
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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() {