diff --git a/plugins/host/storage.go b/plugins/host/storage.go new file mode 100644 index 000000000..ed24d7508 --- /dev/null +++ b/plugins/host/storage.go @@ -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 +} diff --git a/plugins/host/storage_gen.go b/plugins/host/storage_gen.go new file mode 100644 index 000000000..d705f0831 --- /dev/null +++ b/plugins/host/storage_gen.go @@ -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 +} diff --git a/plugins/host_storage.go b/plugins/host_storage.go new file mode 100644 index 000000000..e15a8b8ee --- /dev/null +++ b/plugins/host_storage.go @@ -0,0 +1,34 @@ +package plugins + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/plugins/host" +) + +const STORAGE_MOUNT = "/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 STORAGE_MOUNT +} + +var _ host.StorageService = (*storageServiceImpl)(nil) diff --git a/plugins/host_storage_test.go b/plugins/host_storage_test.go new file mode 100644 index 000000000..f08739c65 --- /dev/null +++ b/plugins/host_storage_test.go @@ -0,0 +1,218 @@ +//go:build !windows + +package plugins + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + + "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()) + + // Copy test plugin to temp dir + srcPath := filepath.Join(testdataDir, ID+PackageExtension) + destPath := filepath.Join(tmpDir, ID+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // 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 + + // 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) + + // 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, ID+PackageExtension) + wasmData, err := os.ReadFile(pluginPath) + Expect(err).ToNot(HaveOccurred()) + hash := sha256.Sum256(wasmData) + hashHex := hex.EncodeToString(hash[:]) + + mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo) + mockPluginRepo.Permitted = true + enabledPlugin := model.Plugin{ + ID: ID, + Path: pluginPath, + SHA256: hashHex, + Enabled: true, + AllUsers: true, // Allow all users for test plugin + } + mockPluginRepo.SetData(model.Plugins{enabledPlugin}) + + // 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(MatchError("failed to read file: open /storage/missing: file does not exist")) + }) + + 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 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 write to nested file", func() { + instance, err := plugin.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(MatchError("failed to write file: open /storage/nested/file: file does not exist")) + }) + + It("should write to a file", func() { + instance, err := plugin.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"))) + }) + }) +}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 675c85e26..1969c9b2c 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "maps" "time" extism "github.com/extism/go-sdk" @@ -168,6 +169,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. @@ -325,14 +337,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)] = STORAGE_MOUNT } - allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess) pluginManifest.AllowedPaths = allowedPaths } diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 29e5d1fc7..b3fbe40f8 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -116,6 +116,9 @@ }, "matcher": { "$ref": "#/$defs/MatcherPermission" + }, + "storage": { + "$ref": "#/$defs/StoragePermission" } } }, @@ -268,6 +271,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" + } + } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index 6bd0e8049..17fef76a9 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -128,3 +128,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 +} diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 3599eafc4..ae629c4e4 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -187,6 +187,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"` @@ -206,6 +209,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 diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index ff2c2a07f..e4157e29c 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -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. diff --git a/plugins/pdk/go/host/nd_host_storage.go b/plugins/pdk/go/host/nd_host_storage.go new file mode 100644 index 000000000..4f37bc8e2 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_storage.go @@ -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 +} diff --git a/plugins/pdk/go/host/nd_host_storage_stub.go b/plugins/pdk/go/host/nd_host_storage_stub.go new file mode 100644 index 000000000..c2213ece9 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_storage_stub.go @@ -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() +} diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index cc1fdc190..3da023f5c 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -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. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs new file mode 100644 index 000000000..336ae565f --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs @@ -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) -> Json; +} + +/// 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 { + let response = unsafe { + storage_getstoragepath(Json(serde_json::json!({})))? + }; + + Ok(response.0.result) +} diff --git a/plugins/testdata/test-storage-plugin/go.mod b/plugins/testdata/test-storage-plugin/go.mod new file mode 100644 index 000000000..7b9a2b567 --- /dev/null +++ b/plugins/testdata/test-storage-plugin/go.mod @@ -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 diff --git a/plugins/testdata/test-storage-plugin/go.sum b/plugins/testdata/test-storage-plugin/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-storage-plugin/go.sum @@ -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= diff --git a/plugins/testdata/test-storage-plugin/main.go b/plugins/testdata/test-storage-plugin/main.go new file mode 100644 index 000000000..67f0d31a0 --- /dev/null +++ b/plugins/testdata/test-storage-plugin/main.go @@ -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() {} diff --git a/plugins/testdata/test-storage-plugin/manifest.json b/plugins/testdata/test-storage-plugin/manifest.json new file mode 100644 index 000000000..9354dc3ed --- /dev/null +++ b/plugins/testdata/test-storage-plugin/manifest.json @@ -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" + } + } +}