mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(plugins): implement plugin specific storage (#5839)
* implement storage api/hooks * fine. if the error messages are different, just match error * rename storageMount * add independent plugin test * round 2 * .-. * one more copypasta fail * docs(plugins): document the Storage host service The README is the plugin author reference and every other host service has a section there, but Storage had none, so the /storage guest path contract only existed in code. Documents the mount point and its backing directory, the manifest permission, the host function, and the two behaviours an author would otherwise discover the hard way: there is no size limit, and the directory outlives an uninstall. * docs(plugins): correct the library filesystem security notes The README stated in three places that library filesystem access is read-only, which stopped being true when AllowWriteAccess was added: an administrator can grant a plugin write access to libraries. Corrects those and gives the library and storage mounts the same wording for what the sandbox guarantees, since both now go through the same jail. * docs(plugins): describe symlink handling in mounts accurately The security notes claimed paths resolving outside a mount are rejected, which overstates the jail. Only lexical escapes are: '..' and absolute paths. Creating symlinks is denied, but symlinks already present are followed and do reach outside the mount, which is what lets music libraries link folders in from elsewhere. Both behaviours are pinned by tests in the plugins package. --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
parent
77726af59c
commit
54fe6c254e
@ -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
13
plugins/host/storage.go
Normal 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
|
||||
}
|
||||
67
plugins/host/storage_gen.go
Normal file
67
plugins/host/storage_gen.go
Normal 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
34
plugins/host_storage.go
Normal 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)
|
||||
270
plugins/host_storage_test.go
Normal file
270
plugins/host_storage_test.go
Normal 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])))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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.
|
||||
@ -315,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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
46
plugins/pdk/go/host/nd_host_storage.go
Normal file
46
plugins/pdk/go/host/nd_host_storage.go
Normal 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
|
||||
}
|
||||
34
plugins/pdk/go/host/nd_host_storage_stub.go
Normal file
34
plugins/pdk/go/host/nd_host_storage_stub.go
Normal 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()
|
||||
}
|
||||
@ -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.
|
||||
|
||||
34
plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs
Normal file
34
plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs
Normal 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
1
plugins/testdata/test-storage-plugin-2
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
test-storage-plugin
|
||||
16
plugins/testdata/test-storage-plugin/go.mod
vendored
Normal file
16
plugins/testdata/test-storage-plugin/go.mod
vendored
Normal 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
|
||||
14
plugins/testdata/test-storage-plugin/go.sum
vendored
Normal file
14
plugins/testdata/test-storage-plugin/go.sum
vendored
Normal 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=
|
||||
50
plugins/testdata/test-storage-plugin/main.go
vendored
Normal file
50
plugins/testdata/test-storage-plugin/main.go
vendored
Normal 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() {}
|
||||
11
plugins/testdata/test-storage-plugin/manifest.json
vendored
Normal file
11
plugins/testdata/test-storage-plugin/manifest.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user