mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge 08492bf31b034a544e5d329164806abcd4d47db8 into 600ea5482c36d3705fbca1d9ab749d9bdafd6f80
This commit is contained in:
commit
0f94e108b1
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)
|
||||
264
plugins/host_storage_test.go
Normal file
264
plugins/host_storage_test.go
Normal file
@ -0,0 +1,264 @@
|
||||
//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
|
||||
router *fakeSubsonicRouter
|
||||
userRepo *tests.MockedUserRepo
|
||||
dataStore *tests.MockDataStore
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Read", func() {
|
||||
var plugin *plugin
|
||||
|
||||
BeforeAll(func() {
|
||||
path := filepath.Join(getHostStoragePath(ID), "real")
|
||||
err := os.WriteFile(path, []byte("1234"), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
manager.mu.RLock()
|
||||
plugin = manager.plugins[ID]
|
||||
manager.mu.RUnlock()
|
||||
Expect(plugin).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should fail to read missing file", func() {
|
||||
instance, err := plugin.instance(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer instance.Close(GinkgoT().Context())
|
||||
|
||||
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() {
|
||||
instance, err := plugin.instance(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer instance.Close(GinkgoT().Context())
|
||||
|
||||
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")))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Write", func() {
|
||||
var p *plugin
|
||||
|
||||
BeforeAll(func() {
|
||||
path := filepath.Join(getHostStoragePath(ID), "real")
|
||||
err := os.WriteFile(path, []byte("1234"), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
manager.mu.RLock()
|
||||
p = manager.plugins[ID]
|
||||
manager.mu.RUnlock()
|
||||
Expect(p).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should fail to write to nested file", func() {
|
||||
instance, err := p.instance(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer instance.Close(GinkgoT().Context())
|
||||
|
||||
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() {
|
||||
instance, err := p.instance(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer instance.Close(GinkgoT().Context())
|
||||
|
||||
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 have independent storage for multiple plugins", func() {
|
||||
manager.mu.RLock()
|
||||
plugin2 := manager.plugins[ID+"-2"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
Expect(plugin2).ToNot(BeNil())
|
||||
|
||||
plugins := []*plugin{p, plugin2}
|
||||
instances := []*extism.Plugin{}
|
||||
names := []string{ID, ID + "-2"}
|
||||
|
||||
for idx := range plugins {
|
||||
instance, err := plugins[idx].instance(GinkgoT().Context())
|
||||
instances = append(instances, instance)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer instance.Close(GinkgoT().Context())
|
||||
|
||||
exit, _, err := instance.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 plugins {
|
||||
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])))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
@ -166,6 +167,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.
|
||||
@ -323,14 +335,24 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
}
|
||||
|
||||
// Configure filesystem access for library permission
|
||||
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.HasStoragePermissions() {
|
||||
allowedPaths := map[string]string{}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
libraryPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
|
||||
maps.Copy(allowedPaths, libraryPaths)
|
||||
}
|
||||
|
||||
if pkg.Manifest.HasStoragePermissions() {
|
||||
allowedPaths[getHostStoragePath(p.ID)] = storageMount
|
||||
}
|
||||
|
||||
allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
|
||||
pluginManifest.AllowedPaths = allowedPaths
|
||||
}
|
||||
|
||||
|
||||
@ -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) HasStoragePermissions() 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-subsonicapi-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 SubsonicAPI Plugin",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "Test plugin for SubsonicAPI host function",
|
||||
"permissions": {
|
||||
"storage": {
|
||||
"reason": "persistent storage for testing"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user