Merge branch 'master' into artwork-blurhash

This commit is contained in:
Deluan Quintão 2026-08-03 21:27:33 -04:00 committed by GitHub
commit 0c06d3fcef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 919 additions and 47 deletions

1
.gitignore vendored
View File

@ -40,3 +40,4 @@ openspec/
.agents
go.work*
.worktrees/
.playwright-mcp/

View File

@ -69,6 +69,7 @@ linters:
- examples$
- node_modules
- _gen\.go$
- .worktrees
formatters:
exclusions:
generated: lax

View File

@ -30,6 +30,7 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag
- [Scheduler](#scheduler)
- [Cache](#cache)
- [KVStore](#kvstore)
- [Storage](#storage)
- [Task](#task)
- [WebSocket](#websocket)
- [Library](#library)
@ -548,6 +549,52 @@ usage, err := host.KVStoreGetStorageUsed()
fmt.Printf("Using %d bytes\n", usage)
```
### Storage
A private read-write directory, mounted into the sandbox at `/storage` and backed by `${DataFolder}/plugins/${pluginID}/storage`. Survives server restarts. Use it for data that doesn't fit a key-value store: caches, downloaded files, generated indexes.
**Manifest permission:**
```json
{
"permissions": {
"storage": {
"reason": "Cache generated playlists between restarts"
}
}
}
```
**Host functions:**
| Function | Parameters | Description |
|--------------------------|------------|------------------------------------|
| `storage_getstoragepath` | – | Get the guest path of the mount |
**Usage:**
Normal WASI filesystem calls work inside the mount, so use the `os` package directly:
```go
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
)
// The path never changes, so read it once instead of per operation
var storageDir = host.StorageGetStoragePath() // "/storage"
err := os.WriteFile(filepath.Join(storageDir, "cache.json"), data, 0600)
content, err := os.ReadFile(filepath.Join(storageDir, "cache.json"))
entries, err := os.ReadDir(storageDir)
```
> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks that already exist in the directory are still followed, so anything linked in from elsewhere remains reachable.
> **Note:** There is no size limit, unlike [KVStore](#kvstore). The directory is not deleted when a plugin is uninstalled.
### Task
Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function.
@ -644,7 +691,7 @@ Access music library metadata and optionally read files from library directories
}
```
- `filesystem` – Set to `true` to enable read-only access to library directories (default: `false`)
- `filesystem` – Set to `true` to enable access to library directories, read-only unless an administrator grants write access (default: `false`)
**Host functions:**
@ -683,7 +730,7 @@ content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
entries, err := os.ReadDir("/libraries/1/Artist")
```
> **Security:** Filesystem access is read-only and restricted to configured library paths only.
> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks already present in the library are still followed, so folders linked in from elsewhere work as expected. Access is read-only unless an administrator grants the plugin write access (`navidrome plugin edit <name> --write-access`).
**Usage:**
@ -1058,7 +1105,7 @@ See [examples/](examples/) for complete working plugins:
Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime:
1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket
2. **Limited File System** – Read-only access to library directories, only when explicitly granted the `library.filesystem` permission
2. **Limited File System** – Plugins cannot create symlinks inside a mount and `..` or absolute paths are rejected, though symlinks already present are followed. Library access requires the `library.filesystem` permission and is read-only unless an administrator grants write access; the `storage` permission grants a read-write directory private to the plugin
3. **No Network Listeners** – Plugins cannot bind ports
4. **Config Isolation** – Plugins only receive their own config section
5. **Memory Limits** – Controlled by the WebAssembly runtime

13
plugins/host/storage.go Normal file
View File

@ -0,0 +1,13 @@
package host
import "context"
// StorageService provides access to a plugin-specific directory with read/write permissions
//
//nd:hostservice name=Storage permission=storage
type StorageService interface {
// GetStoragePath retrieves the persistent storage path, if allowed
//
//nd:hostfunc
GetStoragePath(ctx context.Context) string
}

View File

@ -0,0 +1,67 @@
// Code generated by ndpgen. DO NOT EDIT.
package host
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// StorageGetStoragePathResponse is the response type for Storage.GetStoragePath.
type StorageGetStoragePathResponse struct {
Result string `json:"result,omitempty"`
}
// RegisterStorageHostFunctions registers Storage service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterStorageHostFunctions(service StorageService) []extism.HostFunction {
return []extism.HostFunction{
newStorageGetStoragePathHostFunction(service),
}
}
func newStorageGetStoragePathHostFunction(service StorageService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"storage_getstoragepath",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Call the service method
result := service.GetStoragePath(ctx)
// Write JSON response to plugin memory
resp := StorageGetStoragePathResponse{
Result: result,
}
storageWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// storageWriteResponse writes a JSON response to plugin memory.
func storageWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
storageWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// storageWriteError writes an error response to plugin memory.
func storageWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

34
plugins/host_storage.go Normal file
View File

@ -0,0 +1,34 @@
package plugins
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/plugins/host"
)
const storageMount = "/storage"
type storageServiceImpl struct{}
func getHostStoragePath(pluginName string) string {
return filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName, "storage")
}
func newStorageService(pluginName string) (host.StorageService, error) {
dataDir := getHostStoragePath(pluginName)
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("creating plugin data directory: %w", err)
}
return &storageServiceImpl{}, nil
}
func (s *storageServiceImpl) GetStoragePath(ctx context.Context) string {
return storageMount
}
var _ host.StorageService = (*storageServiceImpl)(nil)

View File

@ -0,0 +1,270 @@
//go:build !windows
package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("utility functions", Ordered, func() {
var tmpDir string
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "storage-test-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(tmpDir)
DeferCleanup(func() {
_ = os.RemoveAll(tmpDir)
})
})
Describe("GetHostStoragePath", func() {
It("should join data folder, plugins, plugin name, and storage", func() {
actual := getHostStoragePath("plugin-name")
expected := filepath.Join(tmpDir, "plugins", "plugin-name", "storage")
Expect(actual).To(Equal(expected))
})
})
Describe("GetStoragePath", func() {
It("should return the fixed path", func() {
impl := storageServiceImpl{}
Expect(impl.GetStoragePath(context.TODO())).To(Equal("/storage"))
})
})
Describe("netStorageService", func() {
It("should create the directory on init", func() {
svc, err := newStorageService("plugin-name")
Expect(err).ToNot(HaveOccurred())
Expect(svc).ToNot(BeNil())
dataDir := filepath.Join(tmpDir, "plugins", "plugin-name", "storage")
Expect(dataDir).To(BeADirectory())
})
})
})
var _ = Describe("Storage Host Function", Ordered, func() {
const ID = "test-storage-plugin"
var (
manager *Manager
tmpDir string
)
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "storage-test-*")
Expect(err).ToNot(HaveOccurred())
// Setup mock router and data store
router := &fakeSubsonicRouter{}
userRepo := tests.CreateMockUserRepo()
dataStore := &tests.MockDataStore{MockedUser: userRepo}
// Create and configure manager
manager = &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
}
manager.SetSubsonicRouter(router)
mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo)
mockPluginRepo.Permitted = true
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(tmpDir)
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
pluginPaths := []string{ID, ID + "-2"}
plugins := []model.Plugin{}
for idx := range pluginPaths {
path := pluginPaths[idx] + PackageExtension
// Copy test plugin to temp dir
srcPath := filepath.Join(testdataDir, path)
destPath := filepath.Join(tmpDir, path)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Pre-enable the plugin in the mock repo so it loads on startup
// Compute SHA256 of the plugin file to match what syncPlugins will compute
pluginPath := filepath.Join(tmpDir, path)
wasmData, err := os.ReadFile(pluginPath)
Expect(err).ToNot(HaveOccurred())
hash := sha256.Sum256(wasmData)
hashHex := hex.EncodeToString(hash[:])
plugins = append(plugins, model.Plugin{
ID: pluginPaths[idx],
Path: pluginPath,
SHA256: hashHex,
Enabled: true,
AllUsers: true, // Allow all users for test plugin
})
}
mockPluginRepo.SetData(plugins)
// Start the manager
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
var instance *extism.Plugin
BeforeEach(func() {
var err error
manager.mu.RLock()
plugin := manager.plugins[ID]
manager.mu.RUnlock()
Expect(plugin).ToNot(BeNil())
ctx := GinkgoT().Context()
instance, err = plugin.instance(ctx)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
instance.Close(ctx)
})
})
Describe("Read", func() {
BeforeAll(func() {
path := filepath.Join(getHostStoragePath(ID), "real")
err := os.WriteFile(path, []byte("1234"), 0600)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = os.Remove(path)
})
})
It("should fail to read missing file", func() {
exit, _, err := instance.Call("call_read", []byte("missing"))
Expect(exit).To(Equal(uint32(1)))
Expect(err).To(HaveOccurred())
})
It("should read an existing file", func() {
exit, output, err := instance.Call("call_read", []byte("real"))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
Expect(output).To(Equal([]byte("1234")))
})
It("should not escape read", func() {
path := filepath.Join(getHostStoragePath(ID), "..", "outside")
err := os.WriteFile(path, []byte("outside"), 0600)
Expect(err).ToNot(HaveOccurred())
exit, _, err := instance.Call("call_read", []byte("../outside"))
Expect(exit).To(Equal(uint32(1)))
Expect(err).To(HaveOccurred())
})
})
Describe("Write", func() {
BeforeAll(func() {
path := filepath.Join(getHostStoragePath(ID), "real")
err := os.WriteFile(path, []byte("1234"), 0600)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = os.Remove(path)
})
})
It("should fail to write to nested file", func() {
exit, _, err := instance.Call("call_write", []byte(`{"path":"nested/file","contents":"1234"}`))
Expect(exit).To(Equal(uint32(1)))
Expect(err).To(HaveOccurred())
})
It("should write to a file", func() {
exit, _, err := instance.Call("call_write", []byte(`{"path":"new","contents":"contents"}`))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
data, err := os.ReadFile(filepath.Join(getHostStoragePath(ID), "new"))
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]byte("contents")))
exit, output, err := instance.Call("call_read", []byte("new"))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
Expect(output).To(Equal([]byte("contents")))
})
It("should not escape writing a file", func() {
path := filepath.Join(getHostStoragePath(ID), "..", "outside")
err := os.WriteFile(path, []byte("outside"), 0600)
Expect(err).ToNot(HaveOccurred())
exit, _, err := instance.Call("call_write", []byte(`{"path":"../new","contents":"contents"}`))
Expect(exit).To(Equal(uint32(1)))
Expect(err).To(HaveOccurred())
})
It("should have independent storage for multiple plugins", func() {
manager.mu.RLock()
plugin2 := manager.plugins[ID+"-2"]
manager.mu.RUnlock()
Expect(plugin2).ToNot(BeNil())
secondInstance, err := plugin2.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer secondInstance.Close(GinkgoT().Context())
instances := []*extism.Plugin{instance, secondInstance}
names := []string{ID, ID + "-2"}
for idx := range instances {
exit, _, err := instances[idx].Call("call_write", fmt.Appendf(nil, `{"path":"new","contents":"%s"}`, names[idx]))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
}
for idx := range names {
data, err := os.ReadFile(filepath.Join(getHostStoragePath(names[idx]), "new"))
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]byte(names[idx])))
}
for idx := range instances {
exit, output, err := instances[idx].Call("call_read", []byte("new"))
Expect(exit).To(Equal(uint32(0)))
Expect(err).ToNot(HaveOccurred())
Expect(output).To(Equal([]byte(names[idx])))
}
})
})
})

View File

@ -166,6 +166,17 @@ var hostServices = []hostServiceEntry{
return host.RegisterTaskHostFunctions(service), service, nil
},
},
{
name: "Storage",
hasPermission: func(p *Permissions) bool { return p != nil && p.Storage != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service, err := newStorageService(ctx.pluginName)
if err != nil {
return nil, nil, err
}
return host.RegisterStorageHostFunctions(service), nil, nil
},
},
}
// extractManifest reads manifest from an .ndp package and computes its SHA-256 hash.
@ -271,6 +282,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
return fmt.Errorf("manager is stopped")
}
if !validPluginID(p.ID) {
return fmt.Errorf("invalid plugin ID %q", p.ID)
}
// Track this operation
m.loadWg.Add(1)
defer m.loadWg.Done()
@ -311,14 +326,29 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
// Configure filesystem access for library permission, applied per instance
var fsConfig wazero.FSConfig
if pkg.Manifest.HasLibraryFilesystemPermission() {
adminCtx := adminContext(ctx)
libraries, err := m.ds.Library(adminCtx).GetAll()
if err != nil {
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
if pkg.Manifest.HasLibraryFilesystemPermission() || pkg.Manifest.HasStoragePermission() {
mounts := []mount{}
if pkg.Manifest.HasLibraryFilesystemPermission() {
adminCtx := adminContext(ctx)
libraries, err := m.ds.Library(adminCtx).GetAll()
if err != nil {
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
}
mounts = buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
}
fsConfig = buildFSConfig(buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess))
if pkg.Manifest.HasStoragePermission() {
pluginStore := getHostStoragePath(p.ID)
log.Info(ctx, "Granting read-write filesystem access to plugin storage", "path", pluginStore, "id", p.ID)
mounts = append(mounts, mount{
hostPath: pluginStore,
guestPath: storageMount,
})
}
fsConfig = buildFSConfig(mounts)
}
// Build host functions based on permissions from manifest

View File

@ -1,6 +1,7 @@
package plugins
import (
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -27,6 +28,18 @@ var _ = Describe("buildExtismManifest", func() {
})
})
var _ = Describe("loadPluginWithConfig", func() {
// Discovery already rejects these, but a row predating that check, or one
// left behind by a failed sync, must not reach the mount setup
It("refuses a plugin whose ID is not usable as a directory name", func() {
m := &Manager{plugins: make(map[string]*plugin)}
err := m.loadPluginWithConfig(&model.Plugin{ID: "..", Path: "/does/not/matter.ndp"})
Expect(err).To(MatchError(ContainSubstring("invalid plugin ID")))
})
})
var _ = Describe("parsePluginConfig", func() {
It("returns nil for empty string", func() {
result, err := parsePluginConfig("")

View File

@ -152,7 +152,11 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error {
log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir())
continue
}
name := strings.TrimSuffix(entry.Name(), PackageExtension)
name, ok := pluginIDFromPath(entry.Name())
if !ok {
log.Warn(ctx, "Skipping plugin with unusable name", "name", entry.Name())
continue
}
filesOnDisk[name] = filepath.Join(folder, entry.Name())
}
log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk))

View File

@ -12,6 +12,46 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("syncPlugins", func() {
var m *Manager
var repo *tests.MockPluginRepo
var folder string
BeforeEach(func() {
folder = GinkgoT().TempDir()
repo = tests.CreateMockPluginRepo()
repo.SetData(model.Plugins{})
m = &Manager{ds: &tests.MockDataStore{MockedPlugin: repo}}
})
writePackage := func(name string) {
GinkgoHelper()
manifest := &Manifest{Name: "Test Plugin", Author: "Test Author", Version: "1.0.0"}
wasm := []byte{0x00, 0x61, 0x73, 0x6d} // Minimal wasm header
Expect(createTestPackage(filepath.Join(folder, name), manifest, wasm)).To(Succeed())
}
It("registers a plugin with a usable ID", func() {
writePackage("my-plugin" + PackageExtension)
Expect(m.syncPlugins(context.Background(), folder)).To(Succeed())
_, err := repo.Get("my-plugin")
Expect(err).ToNot(HaveOccurred())
})
It("skips packages whose name yields a path-like ID", func() {
writePackage("." + PackageExtension)
writePackage(".." + PackageExtension)
Expect(m.syncPlugins(context.Background(), folder)).To(Succeed())
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(all).To(BeEmpty())
})
})
var _ = Describe("removePluginFromDB", func() {
It("discards buffered scrobbles for the removed plugin", func() {
ctx := context.Background()

View File

@ -89,7 +89,11 @@ func (m *Manager) handleWatcherEvent(event notify.EventInfo) {
return
}
pluginName := strings.TrimSuffix(filepath.Base(path), PackageExtension)
pluginName, ok := pluginIDFromPath(path)
if !ok {
log.Warn(m.ctx, "Ignoring plugin file with unusable name", "path", path)
return
}
log.Trace(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path)

View File

@ -92,6 +92,9 @@
},
"matcher": {
"$ref": "#/$defs/MatcherPermission"
},
"storage": {
"$ref": "#/$defs/StoragePermission"
}
}
},
@ -244,6 +247,17 @@
"description": "Explanation for why matcher access is needed"
}
}
},
"StoragePermission": {
"type": "object",
"description": "Storage permissions for enabling persistent read-write storage exclusively for the plugin",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"description": "Explanation for why storage access is needed"
}
}
}
}
}

View File

@ -123,3 +123,7 @@ func (m *Manifest) HasLibraryFilesystemPermission() bool {
m.Permissions.Library != nil &&
m.Permissions.Library.Filesystem
}
func (m *Manifest) HasStoragePermission() bool {
return m.Permissions != nil && m.Permissions.Storage != nil
}

View File

@ -178,6 +178,9 @@ type Permissions struct {
// Scheduler corresponds to the JSON schema field "scheduler".
Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"`
// Storage corresponds to the JSON schema field "storage".
Storage *StoragePermission `json:"storage,omitempty" yaml:"storage,omitempty" mapstructure:"storage,omitempty"`
// Subsonicapi corresponds to the JSON schema field "subsonicapi".
Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"`
@ -197,6 +200,13 @@ type SchedulerPermission struct {
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// Storage permissions for enabling persistent read-write storage exclusively for
// the plugin
type StoragePermission struct {
// Explanation for why storage access is needed
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// SubsonicAPI service permissions. Requires 'users' permission to be declared.
type SubsonicAPIPermission struct {
// Explanation for why SubsonicAPI access is needed

View File

@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"io"
"path/filepath"
"strings"
)
const (
@ -18,6 +20,26 @@ const (
wasmFileName = "plugin.wasm"
)
// validPluginID reports whether id is usable. It names a directory under
// DataFolder/plugins, so a path-like one would point at another plugin's data.
func validPluginID(id string) bool {
if id == "." || id == ".." || strings.ContainsAny(id, `/\`) || !filepath.IsLocal(id) {
return false
}
// Windows drops trailing dots and spaces, so "foo." and "foo" would end up
// sharing a directory
return strings.TrimRight(id, ". ") == id
}
// pluginIDFromPath derives the plugin ID from a package path.
func pluginIDFromPath(path string) (string, bool) {
id := strings.TrimSuffix(filepath.Base(path), PackageExtension)
if !validPluginID(id) {
return "", false
}
return id, true
}
// ndpPackage represents a loaded .ndp plugin package.
// It contains the manifest and wasm bytes read from the archive.
type ndpPackage struct {

View File

@ -11,6 +11,36 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("pluginIDFromPath", func() {
DescribeTable("derives the ID from the package filename",
func(path, expected string) {
id, ok := pluginIDFromPath(path)
Expect(ok).To(BeTrue())
Expect(id).To(Equal(expected))
},
Entry("plain name", "/plugins/discord-rich-presence.ndp", "discord-rich-presence"),
Entry("name with spaces", "/plugins/My Plugin.ndp", "My Plugin"),
Entry("leading dot", "/plugins/.hidden.ndp", ".hidden"),
Entry("dots inside", "/plugins/v1.2.3.ndp", "v1.2.3"),
)
// The ID becomes a directory name under DataFolder/plugins, so a path-like
// one would let a plugin reach another plugin's data
DescribeTable("rejects IDs that are unsafe as a directory name",
func(path string) {
_, ok := pluginIDFromPath(path)
Expect(ok).To(BeFalse())
},
Entry("empty", "/plugins/.ndp"),
Entry("current directory", "/plugins/..ndp"),
Entry("parent directory", "/plugins/...ndp"),
// Windows drops trailing dots and spaces, so these would share a
// directory with "foo"
Entry("trailing dot", "/plugins/foo..ndp"),
Entry("trailing space", "/plugins/foo .ndp"),
)
})
var _ = Describe("ndpPackage", func() {
var tmpDir string

View File

@ -43,6 +43,7 @@ The following host services are available:
- Library: provides access to music library metadata for plugins.
- Matcher: resolves externally-obtained songs to local library tracks,
- Scheduler: provides task scheduling capabilities for plugins.
- Storage: provides access to a plugin-specific directory with read/write permissions
- SubsonicAPI: provides access to Navidrome's Subsonic API from plugins.
- Task: provides persistent task queues for plugins.
- Users: provides access to user information for plugins.

View File

@ -0,0 +1,46 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains client wrappers for the Storage host service.
// It is intended for use in Navidrome plugins built with TinyGo.
//
//go:build wasip1
package host
import (
"encoding/json"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
// storage_getstoragepath is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user storage_getstoragepath
func storage_getstoragepath(uint64) uint64
type storageGetStoragePathResponse struct {
Result string `json:"result,omitempty"`
}
// StorageGetStoragePath calls the storage_getstoragepath host function.
// GetStoragePath retrieves the persistent storage path, if allowed
func StorageGetStoragePath() string {
// No parameters - allocate empty JSON object
reqMem := pdk.AllocateBytes([]byte("{}"))
defer reqMem.Free()
// Call the host function
responsePtr := storage_getstoragepath(reqMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response storageGetStoragePathResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return ""
}
return response.Result
}

View File

@ -0,0 +1,34 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains mock implementations for non-WASM builds.
// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms.
// Plugin authors can use the exported mock instances to set expectations in tests.
//
//go:build !wasip1
package host
import (
"github.com/stretchr/testify/mock"
)
// mockStorageService is the mock implementation for testing.
type mockStorageService struct {
mock.Mock
}
// StorageMock is the auto-instantiated mock instance for testing.
// Use this to set expectations: host.StorageMock.On("MethodName", args...).Return(values...)
var StorageMock = &mockStorageService{}
// GetStoragePath is the mock method for StorageGetStoragePath.
func (m *mockStorageService) GetStoragePath() string {
args := m.Called()
return args.String(0)
}
// StorageGetStoragePath delegates to the mock instance.
// GetStoragePath retrieves the persistent storage path, if allowed
func StorageGetStoragePath() string {
return StorageMock.GetStoragePath()
}

View File

@ -40,6 +40,7 @@
//! - [`library`] - provides access to music library metadata for plugins.
//! - [`matcher`] - resolves externally-obtained songs to local library tracks,
//! - [`scheduler`] - provides task scheduling capabilities for plugins.
//! - [`storage`] - provides access to a plugin-specific directory with read/write permissions
//! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins.
//! - [`task`] - provides persistent task queues for plugins.
//! - [`users`] - provides access to user information for plugins.
@ -101,6 +102,13 @@ pub mod scheduler {
pub use super::nd_host_scheduler::*;
}
#[doc(hidden)]
mod nd_host_storage;
/// provides access to a plugin-specific directory with read/write permissions
pub mod storage {
pub use super::nd_host_storage::*;
}
#[doc(hidden)]
mod nd_host_subsonicapi;
/// provides access to Navidrome's Subsonic API from plugins.

View File

@ -0,0 +1,34 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains client wrappers for the Storage host service.
// It is intended for use in Navidrome plugins built with extism-pdk.
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StorageGetStoragePathResponse {
#[serde(default)]
result: String,
}
#[host_fn]
extern "ExtismHost" {
fn storage_getstoragepath(input: Json<serde_json::Value>) -> Json<StorageGetStoragePathResponse>;
}
/// GetStoragePath retrieves the persistent storage path, if allowed
///
/// # Returns
/// The result value.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn get_storage_path() -> Result<String, Error> {
let response = unsafe {
storage_getstoragepath(Json(serde_json::json!({})))?
};
Ok(response.0.result)
}

1
plugins/testdata/test-storage-plugin-2 vendored Symbolic link
View File

@ -0,0 +1 @@
test-storage-plugin

View File

@ -0,0 +1,16 @@
module test-storage-plugin
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go

View File

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,50 @@
// Test plugin for Storage host function integration tests.
// Build with: tinygo build -o ../test-storage-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go
package main
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
//go:wasmexport call_read
func callRead() int32 {
path := filepath.Join(host.StorageGetStoragePath(), pdk.InputString())
content, err := os.ReadFile(path)
if err != nil {
pdk.SetErrorString("failed to read file: " + err.Error())
return 1
}
pdk.Output(content)
return 0
}
type WriteInput struct {
Path string `json:"path"`
Contents string `json:"contents"`
}
//go:wasmexport call_write
func callWrite() int32 {
var config WriteInput
err := pdk.InputJSON(&config)
if err != nil {
pdk.SetErrorString("failed to parse json: " + err.Error())
return 1
}
path := filepath.Join(host.StorageGetStoragePath(), config.Path)
err = os.WriteFile(path, []byte(config.Contents), 0600)
if err != nil {
pdk.SetErrorString("failed to write file: " + err.Error())
return 1
}
return 0
}
func main() {}

View File

@ -0,0 +1,11 @@
{
"name": "Test Storage Plugin",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "Test plugin for storage host function",
"permissions": {
"storage": {
"reason": "persistent storage for testing"
}
}
}

View File

@ -5,6 +5,6 @@
<p id="errorMessageDescription" style="text-align:center;font-size:21px;font-family:arial;margin-top:28px">
It looks like we are having trouble connecting.
<br/>
Please check your internet connection and try again.</p>
Please check your internet connection, or try again in a moment.</p>
</body>
</html>

View File

@ -1,7 +1,6 @@
import { jwtDecode } from 'jwt-decode'
import { baseUrl } from './utils'
import config from './config'
import { removeHomeCache } from './utils/removeHomeCache'
// config sent from server may contain authentication info, for example when the user is authenticated
// by a reverse proxy request header
@ -49,7 +48,6 @@ const authProvider = {
storeAuthenticationInfo(response)
// Avoid "going to create admin" dialog after logout/login without a refresh
config.firstTime = false
removeHomeCache()
return response
})
.catch((error) => {

View File

@ -3,7 +3,6 @@ import { v4 as uuidv4 } from 'uuid'
import { baseUrl } from '../utils'
import config from '../config'
import { jwtDecode } from 'jwt-decode'
import { removeHomeCache } from '../utils/removeHomeCache'
const customAuthorizationHeader = 'X-ND-Authorization'
export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id'
@ -27,7 +26,6 @@ const httpClient = (url, options = {}) => {
localStorage.setItem('userId', decoded.uid)
// Avoid going to create admin dialog after logout/login without a refresh
config.firstTime = false
removeHomeCache()
}
return response
})

View File

@ -1,5 +1,7 @@
/* eslint-disable */
import { createNavigationHandler } from './swNavigation'
// documentation: https://developers.google.com/web/tools/workbox/modules/workbox-sw
importScripts('3rdparty/workbox/workbox-sw.js')
@ -36,17 +38,13 @@ self.addEventListener('install', async (event) => {
})
const networkOnly = new workbox.strategies.NetworkOnly()
const navigationHandler = async (params) => {
try {
// Attempt a network request.
return await networkOnly.handle(params)
} catch (error) {
// If it fails, return the cached HTML.
return caches.match(FALLBACK_HTML_URL, {
cacheName: CACHE_NAME,
})
}
}
const fallbackToCachedHtml = () =>
caches.match(FALLBACK_HTML_URL, { cacheName: CACHE_NAME })
const navigationHandler = createNavigationHandler(
networkOnly,
fallbackToCachedHtml,
)
// self.__WB_MANIFEST is default injection point
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST)

14
ui/src/swNavigation.js Normal file
View File

@ -0,0 +1,14 @@
// Lives outside sw.js so it can be unit tested: sw.js only loads inside a
// service worker, where workbox arrives via importScripts.
export const createNavigationHandler =
(networkOnly, offlineFallback) => async (params) => {
try {
// Attempt a network request.
const response = await networkOnly.handle(params)
// A 5xx reaches us as a normal response, but carries no usable app
return response.status >= 500 ? offlineFallback() : response
} catch (error) {
// If it fails, return the cached HTML.
return offlineFallback()
}
}

View File

@ -0,0 +1,62 @@
import { describe, it, expect, vi } from 'vitest'
import { createNavigationHandler } from './swNavigation'
const OFFLINE = { offlinePage: true }
const setup = (networkResult) => {
const networkOnly = {
handle: vi.fn(() =>
networkResult instanceof Error
? Promise.reject(networkResult)
: Promise.resolve(networkResult),
),
}
const offlineFallback = vi.fn(() => Promise.resolve(OFFLINE))
return {
networkOnly,
offlineFallback,
handler: createNavigationHandler(networkOnly, offlineFallback),
}
}
describe('createNavigationHandler', () => {
it('serves the response from the network', async () => {
const response = { status: 200 }
const { handler, offlineFallback } = setup(response)
await expect(handler({})).resolves.toBe(response)
expect(offlineFallback).not.toHaveBeenCalled()
})
it('falls back to the offline page when the network fails', async () => {
const { handler } = setup(new Error('Failed to fetch'))
await expect(handler({})).resolves.toBe(OFFLINE)
})
it.each([500, 502, 503])(
'falls back to the offline page on %i',
async (status) => {
const { handler } = setup({ status })
await expect(handler({})).resolves.toBe(OFFLINE)
},
)
it.each([304, 401, 404])('passes %i through untouched', async (status) => {
const response = { status }
const { handler, offlineFallback } = setup(response)
await expect(handler({})).resolves.toBe(response)
expect(offlineFallback).not.toHaveBeenCalled()
})
it('passes the navigation params to the network strategy', async () => {
const params = { request: { url: '/app/' } }
const { handler, networkOnly } = setup({ status: 200 })
await handler(params)
expect(networkOnly.handle).toHaveBeenCalledWith(params)
})
})

View File

@ -1,20 +0,0 @@
export const removeHomeCache = async () => {
try {
const workboxKey = (await caches.keys()).find((key) =>
key.startsWith('workbox-precache'),
)
if (!workboxKey) return
const workboxCache = await caches.open(workboxKey)
const indexKey = (await workboxCache.keys()).find((key) =>
key.url.includes('app/index.html'),
)
if (indexKey) {
await workboxCache.delete(indexKey)
}
} catch (e) {
// eslint-disable-next-line no-console
console.error('error reading cache', e)
}
}

View File

@ -16,6 +16,9 @@ export default defineConfig({
filename: 'sw.js',
injectManifest: {
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MiB
// index.html is rendered per-user by the server, so a precached copy
// would pin one user's config (and auth payload) across logins
globIgnores: ['index.html'],
},
devOptions: {
enabled: true,