mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
Merge branch 'master' into artwork-blurhash
This commit is contained in:
commit
0c06d3fcef
1
.gitignore
vendored
1
.gitignore
vendored
@ -40,3 +40,4 @@ openspec/
|
||||
.agents
|
||||
go.work*
|
||||
.worktrees/
|
||||
.playwright-mcp/
|
||||
|
||||
@ -69,6 +69,7 @@ linters:
|
||||
- examples$
|
||||
- node_modules
|
||||
- _gen\.go$
|
||||
- .worktrees
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
|
||||
@ -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.
|
||||
@ -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
|
||||
|
||||
@ -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("")
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
20
ui/src/sw.js
20
ui/src/sw.js
@ -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
14
ui/src/swNavigation.js
Normal 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()
|
||||
}
|
||||
}
|
||||
62
ui/src/swNavigation.test.js
Normal file
62
ui/src/swNavigation.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user