mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-14 17:01:17 -05:00
Compare commits
8 Commits
master
...
plugins-en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c260db60c | ||
|
|
fc113d1dc6 | ||
|
|
425fe862ba | ||
|
|
b1a51f9bbe | ||
|
|
9a004fd043 | ||
|
|
5c52bbb130 | ||
|
|
b0f91715b9 | ||
|
|
9f7b6870ac |
10
cmd/root.go
10
cmd/root.go
@@ -14,10 +14,13 @@ import (
|
|||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/resources"
|
"github.com/navidrome/navidrome/resources"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/scheduler"
|
"github.com/navidrome/navidrome/scheduler"
|
||||||
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/server/backgrounds"
|
"github.com/navidrome/navidrome/server/backgrounds"
|
||||||
|
"github.com/navidrome/navidrome/server/subsonic"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
@@ -138,6 +141,13 @@ func startServer(ctx context.Context) func() error {
|
|||||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||||
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
||||||
}
|
}
|
||||||
|
if conf.Server.Plugins.Enabled {
|
||||||
|
manager := GetPluginManager(ctx)
|
||||||
|
ds := CreateDataStore()
|
||||||
|
endpointRouter := plugins.NewEndpointRouter(manager, ds, subsonic.ValidateAuth, server.Authenticator)
|
||||||
|
a.MountRouter("Plugin Endpoints", consts.URLPathPluginEndpoints, endpointRouter)
|
||||||
|
a.MountRouter("Plugin Subsonic Endpoints", consts.URLPathPluginSubsonicEndpoints, endpointRouter)
|
||||||
|
}
|
||||||
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
|
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,11 +239,13 @@ type inspectOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type pluginsOptions struct {
|
type pluginsOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Folder string
|
Folder string
|
||||||
CacheSize string
|
CacheSize string
|
||||||
AutoReload bool
|
AutoReload bool
|
||||||
LogLevel string
|
LogLevel string
|
||||||
|
EndpointRequestLimit int
|
||||||
|
EndpointRequestWindow time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type extAuthOptions struct {
|
type extAuthOptions struct {
|
||||||
@@ -671,6 +673,8 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("plugins.enabled", true)
|
viper.SetDefault("plugins.enabled", true)
|
||||||
viper.SetDefault("plugins.cachesize", "200MB")
|
viper.SetDefault("plugins.cachesize", "200MB")
|
||||||
viper.SetDefault("plugins.autoreload", false)
|
viper.SetDefault("plugins.autoreload", false)
|
||||||
|
viper.SetDefault("plugins.endpointrequestlimit", 60)
|
||||||
|
viper.SetDefault("plugins.endpointrequestwindow", time.Minute)
|
||||||
|
|
||||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||||
viper.SetDefault("devlogsourceline", false)
|
viper.SetDefault("devlogsourceline", false)
|
||||||
|
|||||||
@@ -36,11 +36,13 @@ const (
|
|||||||
DevInitialUserName = "admin"
|
DevInitialUserName = "admin"
|
||||||
DevInitialName = "Dev Admin"
|
DevInitialName = "Dev Admin"
|
||||||
|
|
||||||
URLPathUI = "/app"
|
URLPathUI = "/app"
|
||||||
URLPathNativeAPI = "/api"
|
URLPathNativeAPI = "/api"
|
||||||
URLPathSubsonicAPI = "/rest"
|
URLPathSubsonicAPI = "/rest"
|
||||||
URLPathPublic = "/share"
|
URLPathPluginEndpoints = "/ext"
|
||||||
URLPathPublicImages = URLPathPublic + "/img"
|
URLPathPluginSubsonicEndpoints = "/rest/ext"
|
||||||
|
URLPathPublic = "/share"
|
||||||
|
URLPathPublicImages = URLPathPublic + "/img"
|
||||||
|
|
||||||
// DefaultUILoginBackgroundURL uses Navidrome curated background images collection,
|
// DefaultUILoginBackgroundURL uses Navidrome curated background images collection,
|
||||||
// available at https://unsplash.com/collections/20072696/navidrome
|
// available at https://unsplash.com/collections/20072696/navidrome
|
||||||
|
|||||||
52
plugins/capabilities/http_endpoint.go
Normal file
52
plugins/capabilities/http_endpoint.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package capabilities
|
||||||
|
|
||||||
|
// HTTPEndpoint allows plugins to handle incoming HTTP requests.
|
||||||
|
// Plugins that declare the 'endpoints' permission must implement this capability.
|
||||||
|
// The host dispatches incoming HTTP requests to the plugin's HandleRequest function.
|
||||||
|
//
|
||||||
|
//nd:capability name=httpendpoint required=true
|
||||||
|
type HTTPEndpoint interface {
|
||||||
|
// HandleRequest processes an incoming HTTP request and returns a response.
|
||||||
|
//nd:export name=nd_http_handle_request raw=true
|
||||||
|
HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||||
|
type HTTPHandleRequest struct {
|
||||||
|
// Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||||
|
Method string `json:"method"`
|
||||||
|
// Path is the request path relative to the plugin's base URL.
|
||||||
|
// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||||
|
// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||||
|
Path string `json:"path"`
|
||||||
|
// Query is the raw query string without the leading '?'.
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
// Headers contains the HTTP request headers.
|
||||||
|
Headers map[string][]string `json:"headers,omitempty"`
|
||||||
|
// Body is the request body content.
|
||||||
|
Body []byte `json:"body,omitempty"`
|
||||||
|
// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||||
|
User *HTTPUser `json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPUser contains authenticated user information passed to the plugin.
|
||||||
|
type HTTPUser struct {
|
||||||
|
// ID is the internal Navidrome user ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Username is the user's login name.
|
||||||
|
Username string `json:"username"`
|
||||||
|
// Name is the user's display name.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// IsAdmin indicates whether the user has admin privileges.
|
||||||
|
IsAdmin bool `json:"isAdmin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||||
|
type HTTPHandleResponse struct {
|
||||||
|
// Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||||
|
Status int32 `json:"status,omitempty"`
|
||||||
|
// Headers contains the HTTP response headers to set.
|
||||||
|
Headers map[string][]string `json:"headers,omitempty"`
|
||||||
|
// Body is the response body content.
|
||||||
|
Body []byte `json:"body,omitempty"`
|
||||||
|
}
|
||||||
81
plugins/capabilities/http_endpoint.yaml
Normal file
81
plugins/capabilities/http_endpoint.yaml
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
version: v1-draft
|
||||||
|
exports:
|
||||||
|
nd_http_handle_request:
|
||||||
|
description: HandleRequest processes an incoming HTTP request and returns a response.
|
||||||
|
input:
|
||||||
|
$ref: '#/components/schemas/HTTPHandleRequest'
|
||||||
|
contentType: application/json
|
||||||
|
output:
|
||||||
|
$ref: '#/components/schemas/HTTPHandleResponse'
|
||||||
|
contentType: application/json
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
HTTPHandleRequest:
|
||||||
|
description: HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||||
|
properties:
|
||||||
|
method:
|
||||||
|
type: string
|
||||||
|
description: Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: |-
|
||||||
|
Path is the request path relative to the plugin's base URL.
|
||||||
|
For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||||
|
Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||||
|
query:
|
||||||
|
type: string
|
||||||
|
description: Query is the raw query string without the leading '?'.
|
||||||
|
headers:
|
||||||
|
type: object
|
||||||
|
description: Headers contains the HTTP request headers.
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: buffer
|
||||||
|
description: Body is the request body content.
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/HTTPUser'
|
||||||
|
description: User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||||
|
nullable: true
|
||||||
|
required:
|
||||||
|
- method
|
||||||
|
- path
|
||||||
|
HTTPHandleResponse:
|
||||||
|
description: HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
description: Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||||
|
headers:
|
||||||
|
type: object
|
||||||
|
description: Headers contains the HTTP response headers to set.
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: buffer
|
||||||
|
description: Body is the response body content.
|
||||||
|
HTTPUser:
|
||||||
|
description: HTTPUser contains authenticated user information passed to the plugin.
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: ID is the internal Navidrome user ID.
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Username is the user's login name.
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Name is the user's display name.
|
||||||
|
isAdmin:
|
||||||
|
type: boolean
|
||||||
|
description: IsAdmin indicates whether the user has admin privileges.
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- username
|
||||||
|
- name
|
||||||
|
- isAdmin
|
||||||
14
plugins/capability_http_endpoint.go
Normal file
14
plugins/capability_http_endpoint.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
// CapabilityHTTPEndpoint indicates the plugin can handle incoming HTTP requests.
|
||||||
|
// Detected when the plugin exports the nd_http_handle_request function.
|
||||||
|
const CapabilityHTTPEndpoint Capability = "HTTPEndpoint"
|
||||||
|
|
||||||
|
const FuncHTTPHandleRequest = "nd_http_handle_request"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerCapability(
|
||||||
|
CapabilityHTTPEndpoint,
|
||||||
|
FuncHTTPHandleRequest,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -364,6 +364,27 @@ func capabilityFuncMap(cap Capability) template.FuncMap {
|
|||||||
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
|
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
|
||||||
"implVar": func(e Export) string { return e.ImplVarName() },
|
"implVar": func(e Export) string { return e.ImplVarName() },
|
||||||
"exportFunc": func(e Export) string { return e.ExportFuncName() },
|
"exportFunc": func(e Export) string { return e.ExportFuncName() },
|
||||||
|
"rawFieldName": rawFieldName(cap),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawFieldName returns a template function that finds the first []byte field name
|
||||||
|
// in a struct by type name. This is used by raw export templates to generate
|
||||||
|
// field-specific binary frame code.
|
||||||
|
func rawFieldName(cap Capability) func(string) string {
|
||||||
|
structMap := make(map[string]StructDef)
|
||||||
|
for _, s := range cap.Structs {
|
||||||
|
structMap[s.Name] = s
|
||||||
|
}
|
||||||
|
return func(typeName string) string {
|
||||||
|
if s, ok := structMap[typeName]; ok {
|
||||||
|
for _, f := range s.Fields {
|
||||||
|
if f.Type == "[]byte" {
|
||||||
|
return f.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,6 +487,7 @@ func rustCapabilityFuncMap(cap Capability) template.FuncMap {
|
|||||||
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
|
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
|
||||||
"registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) },
|
"registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) },
|
||||||
"snakeCase": ToSnakeCase,
|
"snakeCase": ToSnakeCase,
|
||||||
|
"rawFieldName": rawFieldName(cap),
|
||||||
"indent": func(spaces int, s string) string {
|
"indent": func(spaces int, s string) string {
|
||||||
indent := strings.Repeat(" ", spaces)
|
indent := strings.Repeat(" ", spaces)
|
||||||
lines := strings.Split(s, "\n")
|
lines := strings.Split(s, "\n")
|
||||||
@@ -560,9 +582,15 @@ func rustConstName(name string) string {
|
|||||||
|
|
||||||
// skipSerializingFunc returns the appropriate skip_serializing_if function name.
|
// skipSerializingFunc returns the appropriate skip_serializing_if function name.
|
||||||
func skipSerializingFunc(goType string) string {
|
func skipSerializingFunc(goType string) string {
|
||||||
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") {
|
if goType == "[]byte" {
|
||||||
|
return "Vec::is_empty"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") {
|
||||||
return "Option::is_none"
|
return "Option::is_none"
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(goType, "map[") {
|
||||||
|
return "HashMap::is_empty"
|
||||||
|
}
|
||||||
switch goType {
|
switch goType {
|
||||||
case "string":
|
case "string":
|
||||||
return "String::is_empty"
|
return "String::is_empty"
|
||||||
|
|||||||
@@ -1432,12 +1432,20 @@ type OnInitOutput struct {
|
|||||||
|
|
||||||
var _ = Describe("Rust Generation", func() {
|
var _ = Describe("Rust Generation", func() {
|
||||||
Describe("skipSerializingFunc", func() {
|
Describe("skipSerializingFunc", func() {
|
||||||
It("should return Option::is_none for pointer, slice, and map types", func() {
|
It("should return Vec::is_empty for []byte type", func() {
|
||||||
|
Expect(skipSerializingFunc("[]byte")).To(Equal("Vec::is_empty"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should return Option::is_none for pointer and slice types", func() {
|
||||||
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
|
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
|
||||||
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
|
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
|
||||||
Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none"))
|
Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none"))
|
||||||
Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none"))
|
Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none"))
|
||||||
Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none"))
|
})
|
||||||
|
|
||||||
|
It("should return HashMap::is_empty for map types", func() {
|
||||||
|
Expect(skipSerializingFunc("map[string]int")).To(Equal("HashMap::is_empty"))
|
||||||
|
Expect(skipSerializingFunc("map[string]string")).To(Equal("HashMap::is_empty"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should return String::is_empty for string type", func() {
|
It("should return String::is_empty for string type", func() {
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri
|
|||||||
Name: name,
|
Name: name,
|
||||||
ExportName: annotation["name"],
|
ExportName: annotation["name"],
|
||||||
Doc: doc,
|
Doc: doc,
|
||||||
|
Raw: annotation["raw"] == "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capability exports have exactly one input parameter (the struct type)
|
// Capability exports have exactly one input parameter (the struct type)
|
||||||
|
|||||||
@@ -635,6 +635,68 @@ type Output struct {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("ParseCapabilities raw=true", func() {
|
||||||
|
It("should parse raw=true export annotation", func() {
|
||||||
|
src := `package capabilities
|
||||||
|
|
||||||
|
//nd:capability name=httpendpoint required=true
|
||||||
|
type HTTPEndpoint interface {
|
||||||
|
//nd:export name=nd_http_handle_request raw=true
|
||||||
|
HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPHandleRequest struct {
|
||||||
|
Method string ` + "`json:\"method\"`" + `
|
||||||
|
Body []byte ` + "`json:\"body,omitempty\"`" + `
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPHandleResponse struct {
|
||||||
|
Status int32 ` + "`json:\"status,omitempty\"`" + `
|
||||||
|
Body []byte ` + "`json:\"body,omitempty\"`" + `
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "http_endpoint.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
capabilities, err := ParseCapabilities(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(capabilities).To(HaveLen(1))
|
||||||
|
|
||||||
|
cap := capabilities[0]
|
||||||
|
Expect(cap.Methods).To(HaveLen(1))
|
||||||
|
Expect(cap.Methods[0].Raw).To(BeTrue())
|
||||||
|
Expect(cap.HasRawMethods()).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should default Raw to false for export annotations without raw", func() {
|
||||||
|
src := `package capabilities
|
||||||
|
|
||||||
|
//nd:capability name=test required=true
|
||||||
|
type TestCapability interface {
|
||||||
|
//nd:export name=nd_test
|
||||||
|
Test(TestInput) (TestOutput, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestInput struct {
|
||||||
|
Value string ` + "`json:\"value\"`" + `
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestOutput struct {
|
||||||
|
Result string ` + "`json:\"result\"`" + `
|
||||||
|
}
|
||||||
|
`
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
capabilities, err := ParseCapabilities(tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(capabilities).To(HaveLen(1))
|
||||||
|
|
||||||
|
Expect(capabilities[0].Methods[0].Raw).To(BeFalse())
|
||||||
|
Expect(capabilities[0].HasRawMethods()).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("Export helpers", func() {
|
Describe("Export helpers", func() {
|
||||||
It("should generate correct provider interface name", func() {
|
It("should generate correct provider interface name", func() {
|
||||||
e := Export{Name: "GetArtistBiography"}
|
e := Export{Name: "GetArtistBiography"}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ package {{.Package}}
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||||
|
{{- if .Capability.HasRawMethods}}
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
{{- end}}
|
||||||
)
|
)
|
||||||
|
|
||||||
{{- /* Generate type alias definitions */ -}}
|
{{- /* Generate type alias definitions */ -}}
|
||||||
@@ -56,6 +60,7 @@ func (e {{$typeName}}) Error() string { return string(e) }
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
{{- /* Generate struct definitions */ -}}
|
{{- /* Generate struct definitions */ -}}
|
||||||
|
{{- $capability := .Capability}}
|
||||||
{{- range .Capability.Structs}}
|
{{- range .Capability.Structs}}
|
||||||
|
|
||||||
{{- if .Doc}}
|
{{- if .Doc}}
|
||||||
@@ -68,8 +73,12 @@ type {{.Name}} struct {
|
|||||||
{{- if .Doc}}
|
{{- if .Doc}}
|
||||||
{{formatDoc .Doc | indent 1}}
|
{{formatDoc .Doc | indent 1}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
|
||||||
|
{{.Name}} {{.Type}} `json:"-"`
|
||||||
|
{{- else}}
|
||||||
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
|
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
@@ -172,6 +181,53 @@ func {{exportFunc .}}() int32 {
|
|||||||
// Return standard code - host will skip this plugin gracefully
|
// Return standard code - host will skip this plugin gracefully
|
||||||
return NotImplementedCode
|
return NotImplementedCode
|
||||||
}
|
}
|
||||||
|
{{- if .Raw}}
|
||||||
|
{{- /* Raw binary frame input/output */ -}}
|
||||||
|
{{- if .HasInput}}
|
||||||
|
|
||||||
|
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||||
|
raw := pdk.Input()
|
||||||
|
if len(raw) < 4 {
|
||||||
|
pdk.SetErrorString("malformed input frame")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
jsonLen := binary.BigEndian.Uint32(raw[:4])
|
||||||
|
if uint32(len(raw)-4) < jsonLen {
|
||||||
|
pdk.SetErrorString("invalid json length in input frame")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
var input {{.Input.Type}}
|
||||||
|
if err := json.Unmarshal(raw[4:4+jsonLen], &input); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
input.{{rawFieldName .Input.Type}} = raw[4+jsonLen:]
|
||||||
|
{{- end}}
|
||||||
|
{{- if and .HasInput .HasOutput}}
|
||||||
|
|
||||||
|
output, err := {{implVar .}}(input)
|
||||||
|
if err != nil {
|
||||||
|
// Error frame: [0x01][UTF-8 error message]
|
||||||
|
errMsg := []byte(err.Error())
|
||||||
|
errFrame := make([]byte, 1+len(errMsg))
|
||||||
|
errFrame[0] = 0x01
|
||||||
|
copy(errFrame[1:], errMsg)
|
||||||
|
pdk.Output(errFrame)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||||
|
jsonBytes, _ := json.Marshal(output)
|
||||||
|
rawBytes := output.{{rawFieldName .Output.Type}}
|
||||||
|
frame := make([]byte, 1+4+len(jsonBytes)+len(rawBytes))
|
||||||
|
frame[0] = 0x00
|
||||||
|
binary.BigEndian.PutUint32(frame[1:5], uint32(len(jsonBytes)))
|
||||||
|
copy(frame[5:5+len(jsonBytes)], jsonBytes)
|
||||||
|
copy(frame[5+len(jsonBytes):], rawBytes)
|
||||||
|
pdk.Output(frame)
|
||||||
|
{{- end}}
|
||||||
|
{{- else}}
|
||||||
|
{{- /* Standard JSON input/output */ -}}
|
||||||
{{- if .HasInput}}
|
{{- if .HasInput}}
|
||||||
|
|
||||||
var input {{.Input.Type}}
|
var input {{.Input.Type}}
|
||||||
@@ -216,6 +272,7 @@ func {{exportFunc .}}() int32 {
|
|||||||
pdk.SetError(err)
|
pdk.SetError(err)
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
{{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pub const {{rustConstName $v.Name}}: &'static str = {{$v.Value}};
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
{{- /* Generate struct definitions */ -}}
|
{{- /* Generate struct definitions */ -}}
|
||||||
|
{{- $capability := .Capability}}
|
||||||
{{- range .Capability.Structs}}
|
{{- range .Capability.Structs}}
|
||||||
|
|
||||||
{{- if .Doc}}
|
{{- if .Doc}}
|
||||||
@@ -66,13 +67,17 @@ pub struct {{.Name}} {
|
|||||||
{{- if .Doc}}
|
{{- if .Doc}}
|
||||||
{{rustDocComment .Doc | indent 4}}
|
{{rustDocComment .Doc | indent 4}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if .OmitEmpty}}
|
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
|
||||||
|
#[serde(skip)]
|
||||||
|
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||||
|
{{- else if .OmitEmpty}}
|
||||||
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
|
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
|
||||||
|
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||||
{{- else}}
|
{{- else}}
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
{{- end}}
|
|
||||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
@@ -124,6 +129,56 @@ pub trait {{agentName .Capability}} {
|
|||||||
macro_rules! register_{{snakeCase .Package}} {
|
macro_rules! register_{{snakeCase .Package}} {
|
||||||
($plugin_type:ty) => {
|
($plugin_type:ty) => {
|
||||||
{{- range .Capability.Methods}}
|
{{- range .Capability.Methods}}
|
||||||
|
{{- if .Raw}}
|
||||||
|
#[extism_pdk::plugin_fn]
|
||||||
|
pub fn {{.ExportName}}(
|
||||||
|
{{- if .HasInput}}
|
||||||
|
_raw_input: extism_pdk::Raw<Vec<u8>>
|
||||||
|
{{- end}}
|
||||||
|
) -> extism_pdk::FnResult<extism_pdk::Raw<Vec<u8>>> {
|
||||||
|
let plugin = <$plugin_type>::default();
|
||||||
|
{{- if .HasInput}}
|
||||||
|
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||||
|
let raw_bytes = _raw_input.0;
|
||||||
|
if raw_bytes.len() < 4 {
|
||||||
|
let mut err_frame = vec![0x01u8];
|
||||||
|
err_frame.extend_from_slice(b"malformed input frame");
|
||||||
|
return Ok(extism_pdk::Raw(err_frame));
|
||||||
|
}
|
||||||
|
let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize;
|
||||||
|
if json_len > raw_bytes.len() - 4 {
|
||||||
|
let mut err_frame = vec![0x01u8];
|
||||||
|
err_frame.extend_from_slice(b"invalid json length in input frame");
|
||||||
|
return Ok(extism_pdk::Raw(err_frame));
|
||||||
|
}
|
||||||
|
let mut req: $crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}} = serde_json::from_slice(&raw_bytes[4..4+json_len])
|
||||||
|
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||||
|
req.{{rustFieldName (rawFieldName .Input.Type)}} = raw_bytes[4+json_len..].to_vec();
|
||||||
|
{{- end}}
|
||||||
|
{{- if and .HasInput .HasOutput}}
|
||||||
|
match $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req) {
|
||||||
|
Ok(output) => {
|
||||||
|
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||||
|
let json_bytes = serde_json::to_vec(&output)
|
||||||
|
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||||
|
let raw_field = &output.{{rustFieldName (rawFieldName .Output.Type)}};
|
||||||
|
let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len());
|
||||||
|
frame.push(0x00);
|
||||||
|
frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes());
|
||||||
|
frame.extend_from_slice(&json_bytes);
|
||||||
|
frame.extend_from_slice(raw_field);
|
||||||
|
Ok(extism_pdk::Raw(frame))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Error frame: [0x01][UTF-8 error message]
|
||||||
|
let mut err_frame = vec![0x01u8];
|
||||||
|
err_frame.extend_from_slice(e.message.as_bytes());
|
||||||
|
Ok(extism_pdk::Raw(err_frame))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
{{- else}}
|
||||||
#[extism_pdk::plugin_fn]
|
#[extism_pdk::plugin_fn]
|
||||||
pub fn {{.ExportName}}(
|
pub fn {{.ExportName}}(
|
||||||
{{- if .HasInput}}
|
{{- if .HasInput}}
|
||||||
@@ -146,6 +201,7 @@ macro_rules! register_{{snakeCase .Package}} {
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
{{- else}}
|
{{- else}}
|
||||||
@@ -171,6 +227,56 @@ pub trait {{providerInterface .}} {
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! {{registerMacroName .Name}} {
|
macro_rules! {{registerMacroName .Name}} {
|
||||||
($plugin_type:ty) => {
|
($plugin_type:ty) => {
|
||||||
|
{{- if .Raw}}
|
||||||
|
#[extism_pdk::plugin_fn]
|
||||||
|
pub fn {{.ExportName}}(
|
||||||
|
{{- if .HasInput}}
|
||||||
|
_raw_input: extism_pdk::Raw<Vec<u8>>
|
||||||
|
{{- end}}
|
||||||
|
) -> extism_pdk::FnResult<extism_pdk::Raw<Vec<u8>>> {
|
||||||
|
let plugin = <$plugin_type>::default();
|
||||||
|
{{- if .HasInput}}
|
||||||
|
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||||
|
let raw_bytes = _raw_input.0;
|
||||||
|
if raw_bytes.len() < 4 {
|
||||||
|
let mut err_frame = vec![0x01u8];
|
||||||
|
err_frame.extend_from_slice(b"malformed input frame");
|
||||||
|
return Ok(extism_pdk::Raw(err_frame));
|
||||||
|
}
|
||||||
|
let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize;
|
||||||
|
if json_len > raw_bytes.len() - 4 {
|
||||||
|
let mut err_frame = vec![0x01u8];
|
||||||
|
err_frame.extend_from_slice(b"invalid json length in input frame");
|
||||||
|
return Ok(extism_pdk::Raw(err_frame));
|
||||||
|
}
|
||||||
|
let mut req: $crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}} = serde_json::from_slice(&raw_bytes[4..4+json_len])
|
||||||
|
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||||
|
req.{{rustFieldName (rawFieldName .Input.Type)}} = raw_bytes[4+json_len..].to_vec();
|
||||||
|
{{- end}}
|
||||||
|
{{- if and .HasInput .HasOutput}}
|
||||||
|
match $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req) {
|
||||||
|
Ok(output) => {
|
||||||
|
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||||
|
let json_bytes = serde_json::to_vec(&output)
|
||||||
|
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||||
|
let raw_field = &output.{{rustFieldName (rawFieldName .Output.Type)}};
|
||||||
|
let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len());
|
||||||
|
frame.push(0x00);
|
||||||
|
frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes());
|
||||||
|
frame.extend_from_slice(&json_bytes);
|
||||||
|
frame.extend_from_slice(raw_field);
|
||||||
|
Ok(extism_pdk::Raw(frame))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Error frame: [0x01][UTF-8 error message]
|
||||||
|
let mut err_frame = vec![0x01u8];
|
||||||
|
err_frame.extend_from_slice(e.message.as_bytes());
|
||||||
|
Ok(extism_pdk::Raw(err_frame))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
{{- else}}
|
||||||
#[extism_pdk::plugin_fn]
|
#[extism_pdk::plugin_fn]
|
||||||
pub fn {{.ExportName}}(
|
pub fn {{.ExportName}}(
|
||||||
{{- if .HasInput}}
|
{{- if .HasInput}}
|
||||||
@@ -192,6 +298,7 @@ macro_rules! {{registerMacroName .Name}} {
|
|||||||
Ok(())
|
Ok(())
|
||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
|
{{- end}}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func (e {{$typeName}}) Error() string { return string(e) }
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
{{- /* Generate struct definitions */ -}}
|
{{- /* Generate struct definitions */ -}}
|
||||||
|
{{- $capability := .Capability}}
|
||||||
{{- range .Capability.Structs}}
|
{{- range .Capability.Structs}}
|
||||||
|
|
||||||
{{- if .Doc}}
|
{{- if .Doc}}
|
||||||
@@ -65,8 +66,12 @@ type {{.Name}} struct {
|
|||||||
{{- if .Doc}}
|
{{- if .Doc}}
|
||||||
{{formatDoc .Doc | indent 1}}
|
{{formatDoc .Doc | indent 1}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
|
||||||
|
{{.Name}} {{.Type}} `json:"-"`
|
||||||
|
{{- else}}
|
||||||
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
|
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ type ConstDef struct {
|
|||||||
Doc string // Documentation comment
|
Doc string // Documentation comment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasRawMethods returns true if any export in the capability uses raw binary framing.
|
||||||
|
func (c Capability) HasRawMethods() bool {
|
||||||
|
for _, m := range c.Methods {
|
||||||
|
if m.Raw {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// KnownStructs returns a map of struct names defined in this capability.
|
// KnownStructs returns a map of struct names defined in this capability.
|
||||||
func (c Capability) KnownStructs() map[string]bool {
|
func (c Capability) KnownStructs() map[string]bool {
|
||||||
result := make(map[string]bool)
|
result := make(map[string]bool)
|
||||||
@@ -64,6 +74,7 @@ type Export struct {
|
|||||||
Input Param // Single input parameter (the struct type)
|
Input Param // Single input parameter (the struct type)
|
||||||
Output Param // Single output return value (the struct type)
|
Output Param // Single output return value (the struct type)
|
||||||
Doc string // Documentation comment for the method
|
Doc string // Documentation comment for the method
|
||||||
|
Raw bool // If true, uses binary framing instead of JSON for []byte fields
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProviderInterfaceName returns the optional provider interface name.
|
// ProviderInterfaceName returns the optional provider interface name.
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ type (
|
|||||||
Nullable bool `yaml:"nullable,omitempty"`
|
Nullable bool `yaml:"nullable,omitempty"`
|
||||||
Items *xtpProperty `yaml:"items,omitempty"`
|
Items *xtpProperty `yaml:"items,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// xtpMapProperty represents a map property in XTP (type: object with additionalProperties).
|
||||||
|
xtpMapProperty struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Description string `yaml:"description,omitempty"`
|
||||||
|
Nullable bool `yaml:"nullable,omitempty"`
|
||||||
|
AdditionalProperties *xtpProperty `yaml:"additionalProperties"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// GenerateSchema generates an XTP YAML schema from a capability.
|
// GenerateSchema generates an XTP YAML schema from a capability.
|
||||||
@@ -206,7 +214,12 @@ func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema
|
|||||||
|
|
||||||
for _, field := range st.Fields {
|
for _, field := range st.Fields {
|
||||||
propName := getJSONFieldName(field)
|
propName := getJSONFieldName(field)
|
||||||
addToMap(&schema.Properties, propName, buildProperty(field, knownTypes))
|
goType := strings.TrimPrefix(field.Type, "*")
|
||||||
|
if strings.HasPrefix(goType, "map[") {
|
||||||
|
addToMap(&schema.Properties, propName, buildMapProperty(goType, field.Doc, strings.HasPrefix(field.Type, "*"), knownTypes))
|
||||||
|
} else {
|
||||||
|
addToMap(&schema.Properties, propName, buildProperty(field, knownTypes))
|
||||||
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty {
|
if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty {
|
||||||
schema.Required = append(schema.Required, propName)
|
schema.Required = append(schema.Required, propName)
|
||||||
@@ -246,6 +259,12 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
|
|||||||
return prop
|
return prop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle []byte as buffer type (must be checked before generic slice handling)
|
||||||
|
if goType == "[]byte" {
|
||||||
|
prop.Type = "buffer"
|
||||||
|
return prop
|
||||||
|
}
|
||||||
|
|
||||||
// Handle slice types
|
// Handle slice types
|
||||||
if strings.HasPrefix(goType, "[]") {
|
if strings.HasPrefix(goType, "[]") {
|
||||||
elemType := goType[2:]
|
elemType := goType[2:]
|
||||||
@@ -264,6 +283,55 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
|
|||||||
return prop
|
return prop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildMapProperty builds an XTP MapProperty for a Go map type.
|
||||||
|
// It parses map[K]V and generates additionalProperties describing V.
|
||||||
|
func buildMapProperty(goType, doc string, isPointer bool, knownTypes map[string]bool) xtpMapProperty {
|
||||||
|
prop := xtpMapProperty{
|
||||||
|
Type: "object",
|
||||||
|
Description: cleanDocForYAML(doc),
|
||||||
|
Nullable: isPointer,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse value type from map[K]V
|
||||||
|
valueType := parseMapValueType(goType)
|
||||||
|
|
||||||
|
valProp := &xtpProperty{}
|
||||||
|
if strings.HasPrefix(valueType, "[]") {
|
||||||
|
elemType := valueType[2:]
|
||||||
|
valProp.Type = "array"
|
||||||
|
valProp.Items = &xtpProperty{}
|
||||||
|
if isKnownType(elemType, knownTypes) {
|
||||||
|
valProp.Items.Ref = "#/components/schemas/" + elemType
|
||||||
|
} else {
|
||||||
|
valProp.Items.Type = goTypeToXTPType(elemType)
|
||||||
|
}
|
||||||
|
} else if isKnownType(valueType, knownTypes) {
|
||||||
|
valProp.Ref = "#/components/schemas/" + valueType
|
||||||
|
} else {
|
||||||
|
valProp.Type, valProp.Format = goTypeToXTPTypeAndFormat(valueType)
|
||||||
|
}
|
||||||
|
prop.AdditionalProperties = valProp
|
||||||
|
|
||||||
|
return prop
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMapValueType extracts the value type from a Go map type string like "map[string][]string".
|
||||||
|
func parseMapValueType(goType string) string {
|
||||||
|
// Find the closing bracket of the key type
|
||||||
|
depth := 0
|
||||||
|
for i, ch := range goType {
|
||||||
|
if ch == '[' {
|
||||||
|
depth++
|
||||||
|
} else if ch == ']' {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return goType[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "object" // fallback
|
||||||
|
}
|
||||||
|
|
||||||
// addToMap adds a key-value pair to a yaml.Node map, preserving insertion order.
|
// addToMap adds a key-value pair to a yaml.Node map, preserving insertion order.
|
||||||
func addToMap[T any](node *yaml.Node, key string, value T) {
|
func addToMap[T any](node *yaml.Node, key string, value T) {
|
||||||
var valNode yaml.Node
|
var valNode yaml.Node
|
||||||
|
|||||||
@@ -719,4 +719,139 @@ var _ = Describe("XTP Schema Generation", func() {
|
|||||||
Expect(schemas).NotTo(HaveKey("UnusedStatus"))
|
Expect(schemas).NotTo(HaveKey("UnusedStatus"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("GenerateSchema with []byte fields", func() {
|
||||||
|
It("should render []byte as buffer type and validate against XTP JSONSchema", func() {
|
||||||
|
capability := Capability{
|
||||||
|
Name: "buffer_test",
|
||||||
|
SourceFile: "buffer_test",
|
||||||
|
Methods: []Export{
|
||||||
|
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||||
|
},
|
||||||
|
Structs: []StructDef{
|
||||||
|
{
|
||||||
|
Name: "Input",
|
||||||
|
Fields: []FieldDef{
|
||||||
|
{Name: "Name", Type: "string", JSONTag: "name"},
|
||||||
|
{Name: "Data", Type: "[]byte", JSONTag: "data,omitempty", OmitEmpty: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Output",
|
||||||
|
Fields: []FieldDef{
|
||||||
|
{Name: "Body", Type: "[]byte", JSONTag: "body,omitempty", OmitEmpty: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
schema, err := GenerateSchema(capability)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||||
|
|
||||||
|
doc := parseSchema(schema)
|
||||||
|
components := doc["components"].(map[string]any)
|
||||||
|
schemas := components["schemas"].(map[string]any)
|
||||||
|
input := schemas["Input"].(map[string]any)
|
||||||
|
props := input["properties"].(map[string]any)
|
||||||
|
data := props["data"].(map[string]any)
|
||||||
|
Expect(data["type"]).To(Equal("buffer"))
|
||||||
|
Expect(data).NotTo(HaveKey("items"))
|
||||||
|
Expect(data).NotTo(HaveKey("format"))
|
||||||
|
|
||||||
|
output := schemas["Output"].(map[string]any)
|
||||||
|
outProps := output["properties"].(map[string]any)
|
||||||
|
body := outProps["body"].(map[string]any)
|
||||||
|
Expect(body["type"]).To(Equal("buffer"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GenerateSchema with map fields", func() {
|
||||||
|
It("should render map[string][]string as object with additionalProperties and validate", func() {
|
||||||
|
capability := Capability{
|
||||||
|
Name: "map_test",
|
||||||
|
SourceFile: "map_test",
|
||||||
|
Methods: []Export{
|
||||||
|
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||||
|
},
|
||||||
|
Structs: []StructDef{
|
||||||
|
{
|
||||||
|
Name: "Input",
|
||||||
|
Fields: []FieldDef{
|
||||||
|
{Name: "Headers", Type: "map[string][]string", JSONTag: "headers,omitempty", OmitEmpty: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Output",
|
||||||
|
Fields: []FieldDef{
|
||||||
|
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
schema, err := GenerateSchema(capability)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||||
|
|
||||||
|
doc := parseSchema(schema)
|
||||||
|
components := doc["components"].(map[string]any)
|
||||||
|
schemas := components["schemas"].(map[string]any)
|
||||||
|
input := schemas["Input"].(map[string]any)
|
||||||
|
props := input["properties"].(map[string]any)
|
||||||
|
headers := props["headers"].(map[string]any)
|
||||||
|
Expect(headers).To(HaveKey("additionalProperties"))
|
||||||
|
addlProps := headers["additionalProperties"].(map[string]any)
|
||||||
|
Expect(addlProps["type"]).To(Equal("array"))
|
||||||
|
items := addlProps["items"].(map[string]any)
|
||||||
|
Expect(items["type"]).To(Equal("string"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should render map[string]string as object with string additionalProperties", func() {
|
||||||
|
capability := Capability{
|
||||||
|
Name: "map_string_test",
|
||||||
|
SourceFile: "map_string_test",
|
||||||
|
Methods: []Export{
|
||||||
|
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||||
|
},
|
||||||
|
Structs: []StructDef{
|
||||||
|
{
|
||||||
|
Name: "Input",
|
||||||
|
Fields: []FieldDef{
|
||||||
|
{Name: "Metadata", Type: "map[string]string", JSONTag: "metadata,omitempty", OmitEmpty: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Output",
|
||||||
|
Fields: []FieldDef{
|
||||||
|
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
schema, err := GenerateSchema(capability)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||||
|
|
||||||
|
doc := parseSchema(schema)
|
||||||
|
components := doc["components"].(map[string]any)
|
||||||
|
schemas := components["schemas"].(map[string]any)
|
||||||
|
input := schemas["Input"].(map[string]any)
|
||||||
|
props := input["properties"].(map[string]any)
|
||||||
|
metadata := props["metadata"].(map[string]any)
|
||||||
|
Expect(metadata).To(HaveKey("additionalProperties"))
|
||||||
|
addlProps := metadata["additionalProperties"].(map[string]any)
|
||||||
|
Expect(addlProps["type"]).To(Equal("string"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("parseMapValueType", func() {
|
||||||
|
DescribeTable("should extract value type from Go map types",
|
||||||
|
func(goType, wantValue string) {
|
||||||
|
Expect(parseMapValueType(goType)).To(Equal(wantValue))
|
||||||
|
},
|
||||||
|
Entry("map[string]string", "map[string]string", "string"),
|
||||||
|
Entry("map[string]int", "map[string]int", "int"),
|
||||||
|
Entry("map[string][]string", "map[string][]string", "[]string"),
|
||||||
|
Entry("map[string][]byte", "map[string][]byte", "[]byte"),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,27 +26,19 @@ const subsonicAPIVersion = "1.16.1"
|
|||||||
// URL Format: Only the path and query parameters are used - host/protocol are ignored.
|
// URL Format: Only the path and query parameters are used - host/protocol are ignored.
|
||||||
// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format).
|
// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format).
|
||||||
type subsonicAPIServiceImpl struct {
|
type subsonicAPIServiceImpl struct {
|
||||||
pluginID string
|
pluginName string
|
||||||
router SubsonicRouter
|
router SubsonicRouter
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
userAccess UserAccess
|
||||||
allUsers bool // If true, plugin can access all users
|
|
||||||
userIDMap map[string]struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
|
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
|
||||||
func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, allowedUserIDs []string, allUsers bool) host.SubsonicAPIService {
|
func newSubsonicAPIService(pluginName string, router SubsonicRouter, ds model.DataStore, userAccess UserAccess) host.SubsonicAPIService {
|
||||||
userIDMap := make(map[string]struct{})
|
|
||||||
for _, id := range allowedUserIDs {
|
|
||||||
userIDMap[id] = struct{}{}
|
|
||||||
}
|
|
||||||
return &subsonicAPIServiceImpl{
|
return &subsonicAPIServiceImpl{
|
||||||
pluginID: pluginID,
|
pluginName: pluginName,
|
||||||
router: router,
|
router: router,
|
||||||
ds: ds,
|
ds: ds,
|
||||||
allowedUserIDs: allowedUserIDs,
|
userAccess: userAccess,
|
||||||
allUsers: allUsers,
|
|
||||||
userIDMap: userIDMap,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,12 +66,12 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.checkPermissions(ctx, username); err != nil {
|
if err := s.checkPermissions(ctx, username); err != nil {
|
||||||
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err)
|
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginName, "user", username, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add required Subsonic API parameters
|
// Add required Subsonic API parameters
|
||||||
query.Set("c", s.pluginID) // Client name (plugin ID)
|
query.Set("c", s.pluginName) // Client name (plugin ID)
|
||||||
query.Set("v", subsonicAPIVersion) // API version
|
query.Set("v", subsonicAPIVersion) // API version
|
||||||
if setJSON {
|
if setJSON {
|
||||||
query.Set("f", "json") // Response format
|
query.Set("f", "json") // Response format
|
||||||
@@ -94,11 +86,8 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string,
|
|||||||
RawQuery: query.Encode(),
|
RawQuery: query.Encode(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create HTTP request with a fresh context to avoid Chi RouteContext pollution.
|
// Use http.NewRequest (not WithContext) to avoid inheriting Chi RouteContext;
|
||||||
// Using http.NewRequest (instead of http.NewRequestWithContext) ensures the internal
|
// auth context is set explicitly below via request.WithInternalAuth.
|
||||||
// SubsonicAPI call doesn't inherit routing information from the parent handler,
|
|
||||||
// which would cause Chi to invoke the wrong handler. Authentication context is
|
|
||||||
// explicitly added in the next step via request.WithInternalAuth.
|
|
||||||
httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
|
httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||||
@@ -135,14 +124,13 @@ func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
|
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
|
||||||
// If allUsers is true, allow any user
|
if s.userAccess.allUsers {
|
||||||
if s.allUsers {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must have at least one allowed user ID configured
|
// Must have at least one allowed user configured
|
||||||
if len(s.allowedUserIDs) == 0 {
|
if !s.userAccess.HasConfiguredUsers() {
|
||||||
return fmt.Errorf("no users configured for plugin %s", s.pluginID)
|
return fmt.Errorf("no users configured for plugin %s", s.pluginName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the user by username to get their ID
|
// Look up the user by username to get their ID
|
||||||
@@ -155,7 +143,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the user's ID is in the allowed list
|
// Check if the user's ID is in the allowed list
|
||||||
if _, ok := s.userIDMap[usr.ID]; !ok {
|
if !s.userAccess.IsAllowed(usr.ID) {
|
||||||
return fmt.Errorf("user %s is not authorized for this plugin", username)
|
return fmt.Errorf("user %s is not authorized for this plugin", username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
Context("with specific user IDs allowed", func() {
|
Context("with specific user IDs allowed", func() {
|
||||||
It("blocks users not in the allowed list", func() {
|
It("blocks users not in the allowed list", func() {
|
||||||
// allowedUserIDs contains "user2", but testuser is "user1"
|
// allowedUserIDs contains "user2", but testuser is "user1"
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||||
@@ -278,7 +278,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
|
|
||||||
It("allows users in the allowed list", func() {
|
It("allows users in the allowed list", func() {
|
||||||
// allowedUserIDs contains "user2" which is "alloweduser"
|
// allowedUserIDs contains "user2" which is "alloweduser"
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
response, err := service.Call(ctx, "/ping?u=alloweduser")
|
response, err := service.Call(ctx, "/ping?u=alloweduser")
|
||||||
@@ -288,7 +288,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
|
|
||||||
It("blocks admin users when not in allowed list", func() {
|
It("blocks admin users when not in allowed list", func() {
|
||||||
// allowedUserIDs only contains "user1" (testuser), not "admin1"
|
// allowedUserIDs only contains "user1" (testuser), not "admin1"
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user1"}))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, err := service.Call(ctx, "/ping?u=adminuser")
|
_, err := service.Call(ctx, "/ping?u=adminuser")
|
||||||
@@ -298,7 +298,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
|
|
||||||
It("allows admin users when in allowed list", func() {
|
It("allows admin users when in allowed list", func() {
|
||||||
// allowedUserIDs contains "admin1"
|
// allowedUserIDs contains "admin1"
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"admin1"}))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
response, err := service.Call(ctx, "/ping?u=adminuser")
|
response, err := service.Call(ctx, "/ping?u=adminuser")
|
||||||
@@ -309,7 +309,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
|
|
||||||
Context("with allUsers=true", func() {
|
Context("with allUsers=true", func() {
|
||||||
It("allows all users regardless of allowed list", func() {
|
It("allows all users regardless of allowed list", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
response, err := service.Call(ctx, "/ping?u=testuser")
|
response, err := service.Call(ctx, "/ping?u=testuser")
|
||||||
@@ -318,7 +318,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("allows admin users when allUsers is true", func() {
|
It("allows admin users when allUsers is true", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
response, err := service.Call(ctx, "/ping?u=adminuser")
|
response, err := service.Call(ctx, "/ping?u=adminuser")
|
||||||
@@ -329,7 +329,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
|
|
||||||
Context("with no users configured", func() {
|
Context("with no users configured", func() {
|
||||||
It("returns error when no users are configured", func() {
|
It("returns error when no users are configured", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, nil))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||||
@@ -338,7 +338,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("returns error for empty user list", func() {
|
It("returns error for empty user list", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{}))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||||
@@ -350,7 +350,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
|
|
||||||
Describe("URL Handling", func() {
|
Describe("URL Handling", func() {
|
||||||
It("returns error for missing username parameter", func() {
|
It("returns error for missing username parameter", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, err := service.Call(ctx, "/ping")
|
_, err := service.Call(ctx, "/ping")
|
||||||
@@ -359,7 +359,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("returns error for invalid URL", func() {
|
It("returns error for invalid URL", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, err := service.Call(ctx, "://invalid")
|
_, err := service.Call(ctx, "://invalid")
|
||||||
@@ -368,7 +368,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("extracts endpoint from path correctly", func() {
|
It("extracts endpoint from path correctly", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user1"}))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, err := service.Call(ctx, "/rest/ping.view?u=testuser")
|
_, err := service.Call(ctx, "/rest/ping.view?u=testuser")
|
||||||
@@ -381,7 +381,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
|
|
||||||
Describe("CallRaw", func() {
|
Describe("CallRaw", func() {
|
||||||
It("returns binary data and content-type", func() {
|
It("returns binary data and content-type", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||||
@@ -391,7 +391,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("does not set f=json parameter", func() {
|
It("does not set f=json parameter", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||||
@@ -403,7 +403,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("enforces permission checks", func() {
|
It("enforces permission checks", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||||
@@ -412,7 +412,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("returns error when username is missing", func() {
|
It("returns error when username is missing", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, _, err := service.CallRaw(ctx, "/getCoverArt")
|
_, _, err := service.CallRaw(ctx, "/getCoverArt")
|
||||||
@@ -421,7 +421,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("returns error when router is nil", func() {
|
It("returns error when router is nil", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
|
service := newSubsonicAPIService("test-plugin", nil, dataStore, NewUserAccess(true, nil))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser")
|
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser")
|
||||||
@@ -430,7 +430,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("returns error for invalid URL", func() {
|
It("returns error for invalid URL", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, _, err := service.CallRaw(ctx, "://invalid")
|
_, _, err := service.CallRaw(ctx, "://invalid")
|
||||||
@@ -441,7 +441,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
|||||||
|
|
||||||
Describe("Router Availability", func() {
|
Describe("Router Availability", func() {
|
||||||
It("returns error when router is nil", func() {
|
It("returns error when router is nil", func() {
|
||||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
|
service := newSubsonicAPIService("test-plugin", nil, dataStore, NewUserAccess(true, nil))
|
||||||
|
|
||||||
ctx := GinkgoT().Context()
|
ctx := GinkgoT().Context()
|
||||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type usersServiceImpl struct {
|
type usersServiceImpl struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
allowedUsers []string // User IDs this plugin can access
|
userAccess UserAccess
|
||||||
allUsers bool // If true, plugin can access all users
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) host.UsersService {
|
func newUsersService(ds model.DataStore, userAccess UserAccess) host.UsersService {
|
||||||
return &usersServiceImpl{
|
return &usersServiceImpl{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
allowedUsers: allowedUsers,
|
userAccess: userAccess,
|
||||||
allUsers: allUsers,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,17 +26,9 @@ func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build allowed users map for efficient lookup
|
|
||||||
allowedMap := make(map[string]bool, len(s.allowedUsers))
|
|
||||||
for _, id := range s.allowedUsers {
|
|
||||||
allowedMap[id] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var result []host.User
|
var result []host.User
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
// If allUsers is true, include all users
|
if s.userAccess.IsAllowed(u.ID) {
|
||||||
// Otherwise, only include users in the allowed list
|
|
||||||
if s.allUsers || allowedMap[u.ID] {
|
|
||||||
result = append(result, host.User{
|
result = append(result, host.User{
|
||||||
UserName: u.UserName,
|
UserName: u.UserName,
|
||||||
Name: u.Name,
|
Name: u.Name,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
|||||||
|
|
||||||
Context("with allUsers=true", func() {
|
Context("with allUsers=true", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
service = newUsersService(ds, nil, true)
|
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should return all users", func() {
|
It("should return all users", func() {
|
||||||
@@ -100,7 +100,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
|||||||
Context("with specific allowed users", func() {
|
Context("with specific allowed users", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
// Only allow access to user1 and user3
|
// Only allow access to user1 and user3
|
||||||
service = newUsersService(ds, []string{"user1", "user3"}, false)
|
service = newUsersService(ds, NewUserAccess(false, []string{"user1", "user3"}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should return only allowed users", func() {
|
It("should return only allowed users", func() {
|
||||||
@@ -119,7 +119,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
|||||||
|
|
||||||
Context("with empty allowed users and allUsers=false", func() {
|
Context("with empty allowed users and allUsers=false", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
service = newUsersService(ds, []string{}, false)
|
service = newUsersService(ds, NewUserAccess(false, []string{}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should return no users", func() {
|
It("should return no users", func() {
|
||||||
@@ -132,7 +132,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
|||||||
Context("when datastore returns error", func() {
|
Context("when datastore returns error", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
mockUserRepo.Error = model.ErrNotFound
|
mockUserRepo.Error = model.ErrNotFound
|
||||||
service = newUsersService(ds, nil, true)
|
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should propagate the error", func() {
|
It("should propagate the error", func() {
|
||||||
@@ -170,7 +170,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
|||||||
|
|
||||||
Context("with allUsers=true", func() {
|
Context("with allUsers=true", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
service = newUsersService(ds, nil, true)
|
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should return only admin users", func() {
|
It("should return only admin users", func() {
|
||||||
@@ -185,7 +185,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
|||||||
Context("with specific allowed users including admin", func() {
|
Context("with specific allowed users including admin", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
// Allow access to user1 (admin) and user2 (non-admin)
|
// Allow access to user1 (admin) and user2 (non-admin)
|
||||||
service = newUsersService(ds, []string{"user1", "user2"}, false)
|
service = newUsersService(ds, NewUserAccess(false, []string{"user1", "user2"}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should return only admin users from allowed list", func() {
|
It("should return only admin users from allowed list", func() {
|
||||||
@@ -199,7 +199,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
|||||||
Context("with specific allowed users excluding admin", func() {
|
Context("with specific allowed users excluding admin", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
// Only allow access to non-admin users
|
// Only allow access to non-admin users
|
||||||
service = newUsersService(ds, []string{"user2", "user3"}, false)
|
service = newUsersService(ds, NewUserAccess(false, []string{"user2", "user3"}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should return empty when no admins in allowed list", func() {
|
It("should return empty when no admins in allowed list", func() {
|
||||||
@@ -212,7 +212,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
|||||||
Context("when datastore returns error", func() {
|
Context("when datastore returns error", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
mockUserRepo.Error = model.ErrNotFound
|
mockUserRepo.Error = model.ErrNotFound
|
||||||
service = newUsersService(ds, nil, true)
|
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should propagate the error", func() {
|
It("should propagate the error", func() {
|
||||||
|
|||||||
189
plugins/http_endpoint.go
Normal file
189
plugins/http_endpoint.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/httprate"
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
"github.com/navidrome/navidrome/plugins/capabilities"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxEndpointBodySize = 1 << 20 // 1MB
|
||||||
|
|
||||||
|
// SubsonicAuthValidator validates Subsonic authentication and returns the user.
|
||||||
|
// This is set by the cmd/ package to avoid import cycles (plugins -> server/subsonic).
|
||||||
|
type SubsonicAuthValidator func(ds model.DataStore, r *http.Request) (*model.User, error)
|
||||||
|
|
||||||
|
// NativeAuthMiddleware is an HTTP middleware that authenticates using JWT tokens.
|
||||||
|
// This is set by the cmd/ package to avoid import cycles (plugins -> server).
|
||||||
|
type NativeAuthMiddleware func(ds model.DataStore) func(next http.Handler) http.Handler
|
||||||
|
|
||||||
|
// NewEndpointRouter creates an HTTP handler that dispatches requests to plugin endpoints.
|
||||||
|
// It should be mounted at both /ext and /rest/ext. The handler uses a catch-all pattern
|
||||||
|
// because Chi does not support adding routes after startup, and plugins can be loaded/unloaded
|
||||||
|
// at runtime. Plugin lookup happens per-request under RLock.
|
||||||
|
func NewEndpointRouter(manager *Manager, ds model.DataStore, subsonicAuth SubsonicAuthValidator, nativeAuth NativeAuthMiddleware) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// Apply rate limiting if configured
|
||||||
|
if conf.Server.Plugins.EndpointRequestLimit > 0 {
|
||||||
|
r.Use(httprate.LimitByIP(conf.Server.Plugins.EndpointRequestLimit, conf.Server.Plugins.EndpointRequestWindow))
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &endpointHandler{
|
||||||
|
manager: manager,
|
||||||
|
ds: ds,
|
||||||
|
subsonicAuth: subsonicAuth,
|
||||||
|
nativeAuth: nativeAuth,
|
||||||
|
}
|
||||||
|
r.HandleFunc("/{pluginID}/*", h.ServeHTTP)
|
||||||
|
r.HandleFunc("/{pluginID}", h.ServeHTTP)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type endpointHandler struct {
|
||||||
|
manager *Manager
|
||||||
|
ds model.DataStore
|
||||||
|
subsonicAuth SubsonicAuthValidator
|
||||||
|
nativeAuth NativeAuthMiddleware
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *endpointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pluginID := chi.URLParam(r, "pluginID")
|
||||||
|
|
||||||
|
h.manager.mu.RLock()
|
||||||
|
p, ok := h.manager.plugins[pluginID]
|
||||||
|
h.manager.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok || !hasCapability(p.capabilities, CapabilityHTTPEndpoint) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.manifest.Permissions == nil || p.manifest.Permissions.Endpoints == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authType := p.manifest.Permissions.Endpoints.Auth
|
||||||
|
|
||||||
|
switch authType {
|
||||||
|
case EndpointsPermissionAuthSubsonic:
|
||||||
|
h.serveWithSubsonicAuth(w, r, p)
|
||||||
|
case EndpointsPermissionAuthNative:
|
||||||
|
h.serveWithNativeAuth(w, r, p)
|
||||||
|
case EndpointsPermissionAuthNone:
|
||||||
|
h.dispatch(w, r, p)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Unknown auth type", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *endpointHandler) serveWithSubsonicAuth(w http.ResponseWriter, r *http.Request, p *plugin) {
|
||||||
|
usr, err := h.subsonicAuth(h.ds, r)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(r.Context(), "Plugin endpoint auth failed", "plugin", p.name, "auth", "subsonic", err)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := request.WithUser(r.Context(), *usr)
|
||||||
|
h.dispatch(w, r.WithContext(ctx), p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *endpointHandler) serveWithNativeAuth(w http.ResponseWriter, r *http.Request, p *plugin) {
|
||||||
|
h.nativeAuth(h.ds)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.dispatch(w, r, p)
|
||||||
|
})).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *plugin) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Check user authorization and extract user info (skip for auth:"none")
|
||||||
|
var httpUser *capabilities.HTTPUser
|
||||||
|
if p.manifest.Permissions.Endpoints.Auth != EndpointsPermissionAuthNone {
|
||||||
|
user, ok := request.UserFrom(ctx)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !p.userAccess.IsAllowed(user.ID) {
|
||||||
|
log.Warn(ctx, "Plugin endpoint access denied", "plugin", p.name, "user", user.UserName)
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpUser = &capabilities.HTTPUser{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.UserName,
|
||||||
|
Name: user.Name,
|
||||||
|
IsAdmin: user.IsAdmin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read request body with size limit
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxEndpointBodySize))
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Failed to read request body", "plugin", p.name, err)
|
||||||
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the plugin request
|
||||||
|
// Normalize path: both /ext/plugin and /ext/plugin/ map to ""
|
||||||
|
rawPath := chi.URLParam(r, "*")
|
||||||
|
relPath := ""
|
||||||
|
if rawPath != "" {
|
||||||
|
relPath = "/" + rawPath
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginReq := capabilities.HTTPHandleRequest{
|
||||||
|
Method: r.Method,
|
||||||
|
Path: relPath,
|
||||||
|
Query: r.URL.RawQuery,
|
||||||
|
Headers: r.Header,
|
||||||
|
Body: body,
|
||||||
|
User: httpUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the plugin using binary framing for []byte Body fields
|
||||||
|
resp, err := callPluginFunctionRaw(
|
||||||
|
ctx, p, FuncHTTPHandleRequest,
|
||||||
|
pluginReq, pluginReq.Body,
|
||||||
|
func(r *capabilities.HTTPHandleResponse, raw []byte) { r.Body = raw },
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Plugin endpoint call failed", "plugin", p.name, "path", relPath, err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write response headers from plugin
|
||||||
|
for key, values := range resp.Headers {
|
||||||
|
for _, v := range values {
|
||||||
|
w.Header().Add(key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security hardening: override any plugin-set security headers
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")
|
||||||
|
|
||||||
|
// Write status code (default to 200)
|
||||||
|
status := int(resp.Status)
|
||||||
|
if status == 0 {
|
||||||
|
status = http.StatusOK
|
||||||
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
|
// Write response body
|
||||||
|
if len(resp.Body) > 0 {
|
||||||
|
if _, err := w.Write(resp.Body); err != nil {
|
||||||
|
log.Error(ctx, "Failed to write plugin endpoint response", "plugin", p.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
480
plugins/http_endpoint_test.go
Normal file
480
plugins/http_endpoint_test.go
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeNativeAuth is a mock native auth middleware that authenticates by looking up
|
||||||
|
// the "X-Test-User" header and setting the user in the context.
|
||||||
|
func fakeNativeAuth(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := r.Header.Get("X-Test-User")
|
||||||
|
if username == "" {
|
||||||
|
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := ds.User(r.Context()).FindByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := request.WithUser(r.Context(), *user)
|
||||||
|
ctx = request.WithUsername(ctx, user.UserName)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeSubsonicAuth is a mock subsonic auth that validates by looking up
|
||||||
|
// the "u" query parameter.
|
||||||
|
func fakeSubsonicAuth(ds model.DataStore, r *http.Request) (*model.User, error) {
|
||||||
|
username := r.URL.Query().Get("u")
|
||||||
|
if username == "" {
|
||||||
|
return nil, model.ErrInvalidAuth
|
||||||
|
}
|
||||||
|
user, err := ds.User(r.Context()).FindByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, model.ErrInvalidAuth
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
|
||||||
|
var (
|
||||||
|
manager *Manager
|
||||||
|
tmpDir string
|
||||||
|
userRepo *tests.MockedUserRepo
|
||||||
|
dataStore *tests.MockDataStore
|
||||||
|
router http.Handler
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeAll(func() {
|
||||||
|
var err error
|
||||||
|
tmpDir, err = os.MkdirTemp("", "http-endpoint-test-*")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Copy all test plugins
|
||||||
|
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} {
|
||||||
|
srcPath := filepath.Join(testdataDir, pluginName+PackageExtension)
|
||||||
|
destPath := filepath.Join(tmpDir, pluginName+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.Plugins.Enabled = true
|
||||||
|
conf.Server.Plugins.Folder = tmpDir
|
||||||
|
conf.Server.Plugins.AutoReload = false
|
||||||
|
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||||
|
|
||||||
|
// Setup mock data store
|
||||||
|
userRepo = tests.CreateMockUserRepo()
|
||||||
|
dataStore = &tests.MockDataStore{MockedUser: userRepo}
|
||||||
|
|
||||||
|
// Add test users
|
||||||
|
_ = userRepo.Put(&model.User{
|
||||||
|
ID: "user1",
|
||||||
|
UserName: "testuser",
|
||||||
|
Name: "Test User",
|
||||||
|
IsAdmin: false,
|
||||||
|
})
|
||||||
|
_ = userRepo.Put(&model.User{
|
||||||
|
ID: "admin1",
|
||||||
|
UserName: "adminuser",
|
||||||
|
Name: "Admin User",
|
||||||
|
IsAdmin: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build enabled plugins list
|
||||||
|
var enabledPlugins model.Plugins
|
||||||
|
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} {
|
||||||
|
pluginPath := filepath.Join(tmpDir, pluginName+PackageExtension)
|
||||||
|
data, err := os.ReadFile(pluginPath)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
hashHex := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
enabledPlugins = append(enabledPlugins, model.Plugin{
|
||||||
|
ID: pluginName,
|
||||||
|
Path: pluginPath,
|
||||||
|
SHA256: hashHex,
|
||||||
|
Enabled: true,
|
||||||
|
AllUsers: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup mock plugin repo
|
||||||
|
mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo)
|
||||||
|
mockPluginRepo.Permitted = true
|
||||||
|
mockPluginRepo.SetData(enabledPlugins)
|
||||||
|
|
||||||
|
// Create and start manager
|
||||||
|
manager = &Manager{
|
||||||
|
plugins: make(map[string]*plugin),
|
||||||
|
ds: dataStore,
|
||||||
|
metrics: noopMetricsRecorder{},
|
||||||
|
subsonicRouter: http.NotFoundHandler(),
|
||||||
|
}
|
||||||
|
err = manager.Start(GinkgoT().Context())
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Create the endpoint router with fake auth functions
|
||||||
|
router = NewEndpointRouter(manager, dataStore, fakeSubsonicAuth, fakeNativeAuth)
|
||||||
|
|
||||||
|
DeferCleanup(func() {
|
||||||
|
_ = manager.Stop()
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Plugin Loading", func() {
|
||||||
|
It("loads the authenticated endpoint plugin", func() {
|
||||||
|
manager.mu.RLock()
|
||||||
|
p := manager.plugins["test-http-endpoint"]
|
||||||
|
manager.mu.RUnlock()
|
||||||
|
|
||||||
|
Expect(p).ToNot(BeNil())
|
||||||
|
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Plugin"))
|
||||||
|
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
||||||
|
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("subsonic"))
|
||||||
|
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("loads the native auth endpoint plugin", func() {
|
||||||
|
manager.mu.RLock()
|
||||||
|
p := manager.plugins["test-http-endpoint-native"]
|
||||||
|
manager.mu.RUnlock()
|
||||||
|
|
||||||
|
Expect(p).ToNot(BeNil())
|
||||||
|
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Native Plugin"))
|
||||||
|
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
||||||
|
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("native"))
|
||||||
|
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("loads the public endpoint plugin", func() {
|
||||||
|
manager.mu.RLock()
|
||||||
|
p := manager.plugins["test-http-endpoint-public"]
|
||||||
|
manager.mu.RUnlock()
|
||||||
|
|
||||||
|
Expect(p).ToNot(BeNil())
|
||||||
|
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Public Plugin"))
|
||||||
|
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
||||||
|
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("none"))
|
||||||
|
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Subsonic Auth Endpoints", func() {
|
||||||
|
It("returns hello response with valid auth", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Body.String()).To(Equal("Hello from plugin!"))
|
||||||
|
Expect(w.Header().Get("Content-Type")).To(Equal("text/plain"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns echo response with request details", func() {
|
||||||
|
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser&foo=bar", strings.NewReader("test body"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp["method"]).To(Equal("POST"))
|
||||||
|
Expect(resp["path"]).To(Equal("/echo"))
|
||||||
|
Expect(resp["body"]).To(Equal("test body"))
|
||||||
|
Expect(resp["hasUser"]).To(BeTrue())
|
||||||
|
Expect(resp["username"]).To(Equal("testuser"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns plugin-defined error status", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint/error?u=testuser", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||||
|
Expect(w.Body.String()).To(Equal("Something went wrong"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns plugin 404 for unknown paths", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint/unknown?u=testuser", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||||
|
Expect(w.Body.String()).To(Equal("Not found: /unknown"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 without auth credentials", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint/hello", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 with invalid auth credentials", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=nonexistent", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Native Auth Endpoints", func() {
|
||||||
|
It("returns hello response with valid native auth", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
||||||
|
req.Header.Set("X-Test-User", "testuser")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Body.String()).To(Equal("Hello from native auth plugin!"))
|
||||||
|
Expect(w.Header().Get("Content-Type")).To(Equal("text/plain"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns echo response with user details", func() {
|
||||||
|
req := httptest.NewRequest("POST", "/test-http-endpoint-native/echo?foo=bar", strings.NewReader("native body"))
|
||||||
|
req.Header.Set("X-Test-User", "adminuser")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp["method"]).To(Equal("POST"))
|
||||||
|
Expect(resp["path"]).To(Equal("/echo"))
|
||||||
|
Expect(resp["body"]).To(Equal("native body"))
|
||||||
|
Expect(resp["hasUser"]).To(BeTrue())
|
||||||
|
Expect(resp["username"]).To(Equal("adminuser"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 without auth header", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 with invalid auth header", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
||||||
|
req.Header.Set("X-Test-User", "nonexistent")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Public Endpoints (auth: none)", func() {
|
||||||
|
It("returns webhook response without auth", func() {
|
||||||
|
req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Body.String()).To(Equal("webhook received"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not pass user info to public endpoints", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint-public/check-no-user", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Body.String()).To(Equal("hasUser=false"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Security Headers", func() {
|
||||||
|
It("includes security headers in authenticated endpoint responses", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
|
||||||
|
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("includes security headers in public endpoint responses", func() {
|
||||||
|
req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
|
||||||
|
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("overrides plugin-set security headers", func() {
|
||||||
|
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser", strings.NewReader("body"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
|
||||||
|
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Unknown Plugin", func() {
|
||||||
|
It("returns 404 for nonexistent plugin", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/nonexistent-plugin/hello", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("User Authorization", func() {
|
||||||
|
var restrictedRouter http.Handler
|
||||||
|
|
||||||
|
BeforeAll(func() {
|
||||||
|
// Create a manager with a plugin restricted to specific users
|
||||||
|
restrictedTmpDir, err := os.MkdirTemp("", "http-endpoint-restricted-test-*")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
srcPath := filepath.Join(testdataDir, "test-http-endpoint"+PackageExtension)
|
||||||
|
destPath := filepath.Join(restrictedTmpDir, "test-http-endpoint"+PackageExtension)
|
||||||
|
data, err := os.ReadFile(srcPath)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
err = os.WriteFile(destPath, data, 0600)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
hashHex := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.Plugins.Enabled = true
|
||||||
|
conf.Server.Plugins.Folder = restrictedTmpDir
|
||||||
|
conf.Server.Plugins.AutoReload = false
|
||||||
|
conf.Server.CacheFolder = filepath.Join(restrictedTmpDir, "cache")
|
||||||
|
|
||||||
|
restrictedPluginRepo := tests.CreateMockPluginRepo()
|
||||||
|
restrictedPluginRepo.Permitted = true
|
||||||
|
restrictedPluginRepo.SetData(model.Plugins{{
|
||||||
|
ID: "test-http-endpoint",
|
||||||
|
Path: destPath,
|
||||||
|
SHA256: hashHex,
|
||||||
|
Enabled: true,
|
||||||
|
AllUsers: false,
|
||||||
|
Users: `["admin1"]`, // Only admin1 is allowed
|
||||||
|
}})
|
||||||
|
restrictedDS := &tests.MockDataStore{
|
||||||
|
MockedPlugin: restrictedPluginRepo,
|
||||||
|
MockedUser: userRepo,
|
||||||
|
}
|
||||||
|
|
||||||
|
restrictedManager := &Manager{
|
||||||
|
plugins: make(map[string]*plugin),
|
||||||
|
ds: restrictedDS,
|
||||||
|
metrics: noopMetricsRecorder{},
|
||||||
|
subsonicRouter: http.NotFoundHandler(),
|
||||||
|
}
|
||||||
|
err = restrictedManager.Start(GinkgoT().Context())
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
restrictedRouter = NewEndpointRouter(restrictedManager, restrictedDS, fakeSubsonicAuth, fakeNativeAuth)
|
||||||
|
|
||||||
|
DeferCleanup(func() {
|
||||||
|
_ = restrictedManager.Stop()
|
||||||
|
_ = os.RemoveAll(restrictedTmpDir)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows authorized users", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=adminuser", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
restrictedRouter.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Body.String()).To(Equal("Hello from plugin!"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("denies unauthorized users", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
restrictedRouter.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Request without trailing path", func() {
|
||||||
|
It("handles requests to plugin root", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint-public/webhook", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Binary Response", func() {
|
||||||
|
It("returns raw binary data intact", func() {
|
||||||
|
req := httptest.NewRequest("GET", "/test-http-endpoint/binary?u=testuser", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(w.Header().Get("Content-Type")).To(Equal("image/png"))
|
||||||
|
// PNG header bytes
|
||||||
|
Expect(w.Body.Bytes()).To(Equal([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Request body handling", func() {
|
||||||
|
It("passes request body to the plugin", func() {
|
||||||
|
body := `{"event":"push","ref":"refs/heads/main"}`
|
||||||
|
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(w.Body)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
err = json.Unmarshal(respBody, &resp)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp["body"]).To(Equal(body))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -260,19 +260,10 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build user ID map for fast lookups
|
// Create a new scrobbler adapter for this plugin
|
||||||
userIDMap := make(map[string]struct{})
|
|
||||||
for _, id := range plugin.allowedUserIDs {
|
|
||||||
userIDMap[id] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new scrobbler adapter for this plugin with user authorization config
|
|
||||||
return &ScrobblerPlugin{
|
return &ScrobblerPlugin{
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
plugin: plugin,
|
plugin: plugin,
|
||||||
allowedUserIDs: plugin.allowedUserIDs,
|
|
||||||
allUsers: plugin.allUsers,
|
|
||||||
userIDMap: userIDMap,
|
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -60,39 +61,147 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcN
|
|||||||
startCall := time.Now()
|
startCall := time.Now()
|
||||||
exit, output, err := p.CallWithContext(ctx, funcName, inputBytes)
|
exit, output, err := p.CallWithContext(ctx, funcName, inputBytes)
|
||||||
elapsed := time.Since(startCall)
|
elapsed := time.Since(startCall)
|
||||||
|
|
||||||
|
success := false
|
||||||
|
skipMetrics := false
|
||||||
|
defer func() {
|
||||||
|
if !skipMetrics {
|
||||||
|
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, success, elapsed.Milliseconds())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If context was cancelled, return that error instead of the plugin error
|
// If context was cancelled, return that error instead of the plugin error
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
|
skipMetrics = true
|
||||||
log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed)
|
log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed)
|
||||||
return result, ctx.Err()
|
return result, ctx.Err()
|
||||||
}
|
}
|
||||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
|
||||||
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
|
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
|
||||||
return result, fmt.Errorf("plugin call failed: %w", err)
|
return result, fmt.Errorf("plugin call failed: %w", err)
|
||||||
}
|
}
|
||||||
if exit != 0 {
|
if exit != 0 {
|
||||||
if exit == notImplementedCode {
|
if exit == notImplementedCode {
|
||||||
|
skipMetrics = true
|
||||||
log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start))
|
log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start))
|
||||||
// TODO Should we record metrics for not implemented calls?
|
|
||||||
//plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, true, elapsed.Milliseconds())
|
|
||||||
return result, fmt.Errorf("%w: %s", errNotImplemented, funcName)
|
return result, fmt.Errorf("%w: %s", errNotImplemented, funcName)
|
||||||
}
|
}
|
||||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
|
||||||
return result, fmt.Errorf("plugin call exited with code %d", exit)
|
return result, fmt.Errorf("plugin call exited with code %d", exit)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(output) > 0 {
|
if len(output) > 0 {
|
||||||
err = json.Unmarshal(output, &result)
|
if err = json.Unmarshal(output, &result); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
|
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
|
||||||
|
return result, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record metrics for successful calls (or JSON unmarshal failures)
|
success = true
|
||||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, err == nil, elapsed.Milliseconds())
|
|
||||||
|
|
||||||
log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
|
log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
|
||||||
return result, err
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callPluginFunctionRaw calls a plugin function using binary framing for []byte fields.
|
||||||
|
// The input is JSON-encoded (with []byte field excluded via json:"-"), followed by raw bytes.
|
||||||
|
// The output frame is: [status:1B][json_len:4B][JSON][raw bytes] for success (0x00),
|
||||||
|
// or [0x01][UTF-8 error message] for errors.
|
||||||
|
func callPluginFunctionRaw[I any, O any](
|
||||||
|
ctx context.Context, plugin *plugin, funcName string,
|
||||||
|
input I, rawInputBytes []byte,
|
||||||
|
setRawOutput func(*O, []byte),
|
||||||
|
) (O, error) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
var result O
|
||||||
|
|
||||||
|
p, err := plugin.instance(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("failed to create plugin: %w", err)
|
||||||
|
}
|
||||||
|
defer p.Close(ctx)
|
||||||
|
|
||||||
|
if !p.FunctionExists(funcName) {
|
||||||
|
log.Trace(ctx, "Plugin function not found", "plugin", plugin.name, "function", funcName)
|
||||||
|
return result, fmt.Errorf("%w: %s", errFunctionNotFound, funcName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build input frame: [json_len:4B][JSON][raw bytes]
|
||||||
|
jsonBytes, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("failed to marshal input: %w", err)
|
||||||
|
}
|
||||||
|
const maxFrameSize = 2 << 20 // 2 MiB
|
||||||
|
if len(jsonBytes) > maxFrameSize || len(rawInputBytes) > maxFrameSize {
|
||||||
|
return result, fmt.Errorf("input frame too large")
|
||||||
|
}
|
||||||
|
frame := make([]byte, 4+len(jsonBytes)+len(rawInputBytes))
|
||||||
|
binary.BigEndian.PutUint32(frame[:4], uint32(len(jsonBytes)))
|
||||||
|
copy(frame[4:4+len(jsonBytes)], jsonBytes)
|
||||||
|
copy(frame[4+len(jsonBytes):], rawInputBytes)
|
||||||
|
|
||||||
|
startCall := time.Now()
|
||||||
|
exit, output, err := p.CallWithContext(ctx, funcName, frame)
|
||||||
|
elapsed := time.Since(startCall)
|
||||||
|
|
||||||
|
success := false
|
||||||
|
skipMetrics := false
|
||||||
|
defer func() {
|
||||||
|
if !skipMetrics {
|
||||||
|
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, success, elapsed.Milliseconds())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
skipMetrics = true
|
||||||
|
log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed)
|
||||||
|
return result, ctx.Err()
|
||||||
|
}
|
||||||
|
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
|
||||||
|
return result, fmt.Errorf("plugin call failed: %w", err)
|
||||||
|
}
|
||||||
|
if exit != 0 {
|
||||||
|
if exit == notImplementedCode {
|
||||||
|
skipMetrics = true
|
||||||
|
log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start))
|
||||||
|
return result, fmt.Errorf("%w: %s", errNotImplemented, funcName)
|
||||||
|
}
|
||||||
|
return result, fmt.Errorf("plugin call exited with code %d", exit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse output frame
|
||||||
|
if len(output) < 1 {
|
||||||
|
return result, fmt.Errorf("empty response from plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
statusByte := output[0]
|
||||||
|
if statusByte == 0x01 {
|
||||||
|
return result, fmt.Errorf("plugin error: %s", string(output[1:]))
|
||||||
|
}
|
||||||
|
if statusByte != 0x00 {
|
||||||
|
return result, fmt.Errorf("unknown response status byte: 0x%02x", statusByte)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success frame: [0x00][json_len:4B][JSON][raw bytes]
|
||||||
|
if len(output) < 5 {
|
||||||
|
return result, fmt.Errorf("malformed success response from plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonLen := binary.BigEndian.Uint32(output[1:5])
|
||||||
|
if uint32(len(output)-5) < jsonLen {
|
||||||
|
return result, fmt.Errorf("invalid json length in response frame: %d exceeds available %d bytes", jsonLen, len(output)-5)
|
||||||
|
}
|
||||||
|
jsonData := output[5 : 5+jsonLen]
|
||||||
|
rawData := output[5+jsonLen:]
|
||||||
|
|
||||||
|
if err := json.Unmarshal(jsonData, &result); err != nil {
|
||||||
|
return result, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
setRawOutput(&result, rawData)
|
||||||
|
|
||||||
|
success = true
|
||||||
|
log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extismLogger is a helper to log messages from Extism plugins
|
// extismLogger is a helper to log messages from Extism plugins
|
||||||
|
|||||||
@@ -24,10 +24,9 @@ type serviceContext struct {
|
|||||||
manager *Manager
|
manager *Manager
|
||||||
permissions *Permissions
|
permissions *Permissions
|
||||||
config map[string]string
|
config map[string]string
|
||||||
allowedUsers []string // User IDs this plugin can access
|
userAccess UserAccess // User authorization for this plugin
|
||||||
allUsers bool // If true, plugin can access all users
|
allowedLibraries []int // Library IDs this plugin can access
|
||||||
allowedLibraries []int // Library IDs this plugin can access
|
allLibraries bool // If true, plugin can access all libraries
|
||||||
allLibraries bool // If true, plugin can access all libraries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostServiceEntry defines a host service for table-driven registration.
|
// hostServiceEntry defines a host service for table-driven registration.
|
||||||
@@ -52,7 +51,7 @@ var hostServices = []hostServiceEntry{
|
|||||||
name: "SubsonicAPI",
|
name: "SubsonicAPI",
|
||||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
|
||||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||||
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
|
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.userAccess)
|
||||||
return host.RegisterSubsonicAPIHostFunctions(service), nil
|
return host.RegisterSubsonicAPIHostFunctions(service), nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -115,7 +114,7 @@ var hostServices = []hostServiceEntry{
|
|||||||
name: "Users",
|
name: "Users",
|
||||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil },
|
hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil },
|
||||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||||
service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
|
service := newUsersService(ctx.manager.ds, ctx.userAccess)
|
||||||
return host.RegisterUsersHostFunctions(service), nil
|
return host.RegisterUsersHostFunctions(service), nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -302,13 +301,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
|||||||
var hostFunctions []extism.HostFunction
|
var hostFunctions []extism.HostFunction
|
||||||
var closers []io.Closer
|
var closers []io.Closer
|
||||||
|
|
||||||
|
userAccess := NewUserAccess(p.AllUsers, allowedUsers)
|
||||||
|
|
||||||
svcCtx := &serviceContext{
|
svcCtx := &serviceContext{
|
||||||
pluginName: p.ID,
|
pluginName: p.ID,
|
||||||
manager: m,
|
manager: m,
|
||||||
permissions: pkg.Manifest.Permissions,
|
permissions: pkg.Manifest.Permissions,
|
||||||
config: pluginConfig,
|
config: pluginConfig,
|
||||||
allowedUsers: allowedUsers,
|
userAccess: userAccess,
|
||||||
allUsers: p.AllUsers,
|
|
||||||
allowedLibraries: allowedLibraries,
|
allowedLibraries: allowedLibraries,
|
||||||
allLibraries: p.AllLibraries,
|
allLibraries: p.AllLibraries,
|
||||||
}
|
}
|
||||||
@@ -361,15 +361,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
|||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.plugins[p.ID] = &plugin{
|
m.plugins[p.ID] = &plugin{
|
||||||
name: p.ID,
|
name: p.ID,
|
||||||
path: p.Path,
|
path: p.Path,
|
||||||
manifest: pkg.Manifest,
|
manifest: pkg.Manifest,
|
||||||
compiled: compiled,
|
compiled: compiled,
|
||||||
capabilities: capabilities,
|
capabilities: capabilities,
|
||||||
closers: closers,
|
closers: closers,
|
||||||
metrics: m.metrics,
|
metrics: m.metrics,
|
||||||
allowedUserIDs: allowedUsers,
|
userAccess: userAccess,
|
||||||
allUsers: p.AllUsers,
|
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,14 @@ import (
|
|||||||
|
|
||||||
// plugin represents a loaded plugin
|
// plugin represents a loaded plugin
|
||||||
type plugin struct {
|
type plugin struct {
|
||||||
name string // Plugin name (from filename)
|
name string // Plugin name (from filename)
|
||||||
path string // Path to the wasm file
|
path string // Path to the wasm file
|
||||||
manifest *Manifest
|
manifest *Manifest
|
||||||
compiled *extism.CompiledPlugin
|
compiled *extism.CompiledPlugin
|
||||||
capabilities []Capability // Auto-detected capabilities based on exported functions
|
capabilities []Capability // Auto-detected capabilities based on exported functions
|
||||||
closers []io.Closer // Cleanup functions to call on unload
|
closers []io.Closer // Cleanup functions to call on unload
|
||||||
metrics PluginMetricsRecorder
|
metrics PluginMetricsRecorder
|
||||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
userAccess UserAccess // User authorization for this plugin
|
||||||
allUsers bool // If true, plugin can access all users
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// instance creates a new plugin instance for the given context.
|
// instance creates a new plugin instance for the given context.
|
||||||
|
|||||||
@@ -110,6 +110,33 @@
|
|||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"$ref": "#/$defs/UsersPermission"
|
"$ref": "#/$defs/UsersPermission"
|
||||||
|
},
|
||||||
|
"endpoints": {
|
||||||
|
"$ref": "#/$defs/EndpointsPermission"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"EndpointsPermission": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "HTTP endpoint permissions for registering custom HTTP endpoints on the Navidrome server. Requires 'users' permission when auth is 'native' or 'subsonic'.",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["auth"],
|
||||||
|
"properties": {
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Explanation for why HTTP endpoint registration is needed"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["native", "subsonic", "none"],
|
||||||
|
"description": "Authentication type for plugin endpoints: 'native' (JWT), 'subsonic' (params), or 'none' (public/unauthenticated)"
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Declared endpoint paths (informational, for admin UI display). Relative to plugin base URL.",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ func (m *Manifest) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Endpoints permission with auth 'native' or 'subsonic' requires users permission
|
||||||
|
if m.Permissions != nil && m.Permissions.Endpoints != nil {
|
||||||
|
if m.Permissions.Endpoints.Auth != EndpointsPermissionAuthNone {
|
||||||
|
if m.Permissions.Users == nil {
|
||||||
|
return fmt.Errorf("'endpoints' permission with auth '%s' requires 'users' permission to be declared", m.Permissions.Endpoints.Auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate config schema if present
|
// Validate config schema if present
|
||||||
if m.Config != nil && m.Config.Schema != nil {
|
if m.Config != nil && m.Config.Schema != nil {
|
||||||
if err := validateConfigSchema(m.Config.Schema); err != nil {
|
if err := validateConfigSchema(m.Config.Schema); err != nil {
|
||||||
@@ -64,6 +73,14 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error {
|
|||||||
return fmt.Errorf("scrobbler capability requires 'users' permission to be declared in manifest")
|
return fmt.Errorf("scrobbler capability requires 'users' permission to be declared in manifest")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTPEndpoint capability requires endpoints permission
|
||||||
|
if hasCapability(capabilities, CapabilityHTTPEndpoint) {
|
||||||
|
if m.Permissions == nil || m.Permissions.Endpoints == nil {
|
||||||
|
return fmt.Errorf("HTTP endpoint capability requires 'endpoints' permission to be declared in manifest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package plugins
|
|||||||
|
|
||||||
import "encoding/json"
|
import "encoding/json"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
import "reflect"
|
||||||
|
|
||||||
// Artwork service permissions for generating artwork URLs
|
// Artwork service permissions for generating artwork URLs
|
||||||
type ArtworkPermission struct {
|
type ArtworkPermission struct {
|
||||||
@@ -45,6 +46,71 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTP endpoint permissions for registering custom HTTP endpoints on the Navidrome
|
||||||
|
// server. Requires 'users' permission when auth is 'native' or 'subsonic'.
|
||||||
|
type EndpointsPermission struct {
|
||||||
|
// Authentication type for plugin endpoints: 'native' (JWT), 'subsonic' (params),
|
||||||
|
// or 'none' (public/unauthenticated)
|
||||||
|
Auth EndpointsPermissionAuth `json:"auth" yaml:"auth" mapstructure:"auth"`
|
||||||
|
|
||||||
|
// Declared endpoint paths (informational, for admin UI display). Relative to
|
||||||
|
// plugin base URL.
|
||||||
|
Paths []string `json:"paths,omitempty" yaml:"paths,omitempty" mapstructure:"paths,omitempty"`
|
||||||
|
|
||||||
|
// Explanation for why HTTP endpoint registration is needed
|
||||||
|
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EndpointsPermissionAuth string
|
||||||
|
|
||||||
|
const EndpointsPermissionAuthNative EndpointsPermissionAuth = "native"
|
||||||
|
const EndpointsPermissionAuthNone EndpointsPermissionAuth = "none"
|
||||||
|
const EndpointsPermissionAuthSubsonic EndpointsPermissionAuth = "subsonic"
|
||||||
|
|
||||||
|
var enumValues_EndpointsPermissionAuth = []interface{}{
|
||||||
|
"native",
|
||||||
|
"subsonic",
|
||||||
|
"none",
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements json.Unmarshaler.
|
||||||
|
func (j *EndpointsPermissionAuth) UnmarshalJSON(value []byte) error {
|
||||||
|
var v string
|
||||||
|
if err := json.Unmarshal(value, &v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
for _, expected := range enumValues_EndpointsPermissionAuth {
|
||||||
|
if reflect.DeepEqual(v, expected) {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_EndpointsPermissionAuth, v)
|
||||||
|
}
|
||||||
|
*j = EndpointsPermissionAuth(v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements json.Unmarshaler.
|
||||||
|
func (j *EndpointsPermission) UnmarshalJSON(value []byte) error {
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(value, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, ok := raw["auth"]; raw != nil && !ok {
|
||||||
|
return fmt.Errorf("field auth in EndpointsPermission: required")
|
||||||
|
}
|
||||||
|
type Plain EndpointsPermission
|
||||||
|
var plain Plain
|
||||||
|
if err := json.Unmarshal(value, &plain); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*j = EndpointsPermission(plain)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Experimental features that may change or be removed in future versions
|
// Experimental features that may change or be removed in future versions
|
||||||
type Experimental struct {
|
type Experimental struct {
|
||||||
// Threads corresponds to the JSON schema field "threads".
|
// Threads corresponds to the JSON schema field "threads".
|
||||||
@@ -166,6 +232,9 @@ type Permissions struct {
|
|||||||
// Cache corresponds to the JSON schema field "cache".
|
// Cache corresponds to the JSON schema field "cache".
|
||||||
Cache *CachePermission `json:"cache,omitempty" yaml:"cache,omitempty" mapstructure:"cache,omitempty"`
|
Cache *CachePermission `json:"cache,omitempty" yaml:"cache,omitempty" mapstructure:"cache,omitempty"`
|
||||||
|
|
||||||
|
// Endpoints corresponds to the JSON schema field "endpoints".
|
||||||
|
Endpoints *EndpointsPermission `json:"endpoints,omitempty" yaml:"endpoints,omitempty" mapstructure:"endpoints,omitempty"`
|
||||||
|
|
||||||
// Http corresponds to the JSON schema field "http".
|
// Http corresponds to the JSON schema field "http".
|
||||||
Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"`
|
Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,10 @@ require (
|
|||||||
github.com/extism/go-pdk v1.1.3
|
github.com/extism/go-pdk v1.1.3
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|||||||
126
plugins/pdk/go/httpendpoint/httpendpoint.go
Normal file
126
plugins/pdk/go/httpendpoint/httpendpoint.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// Code generated by ndpgen. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// This file contains export wrappers for the HTTPEndpoint capability.
|
||||||
|
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||||
|
//
|
||||||
|
//go:build wasip1
|
||||||
|
|
||||||
|
package httpendpoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||||
|
type HTTPHandleRequest struct {
|
||||||
|
// Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||||
|
Method string `json:"method"`
|
||||||
|
// Path is the request path relative to the plugin's base URL.
|
||||||
|
// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||||
|
// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||||
|
Path string `json:"path"`
|
||||||
|
// Query is the raw query string without the leading '?'.
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
// Headers contains the HTTP request headers.
|
||||||
|
Headers map[string][]string `json:"headers,omitempty"`
|
||||||
|
// Body is the request body content.
|
||||||
|
Body []byte `json:"-"`
|
||||||
|
// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||||
|
User *HTTPUser `json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||||
|
type HTTPHandleResponse struct {
|
||||||
|
// Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||||
|
Status int32 `json:"status,omitempty"`
|
||||||
|
// Headers contains the HTTP response headers to set.
|
||||||
|
Headers map[string][]string `json:"headers,omitempty"`
|
||||||
|
// Body is the response body content.
|
||||||
|
Body []byte `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPUser contains authenticated user information passed to the plugin.
|
||||||
|
type HTTPUser struct {
|
||||||
|
// ID is the internal Navidrome user ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Username is the user's login name.
|
||||||
|
Username string `json:"username"`
|
||||||
|
// Name is the user's display name.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// IsAdmin indicates whether the user has admin privileges.
|
||||||
|
IsAdmin bool `json:"isAdmin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPEndpoint requires all methods to be implemented.
|
||||||
|
// HTTPEndpoint allows plugins to handle incoming HTTP requests.
|
||||||
|
// Plugins that declare the 'endpoints' permission must implement this capability.
|
||||||
|
// The host dispatches incoming HTTP requests to the plugin's HandleRequest function.
|
||||||
|
type HTTPEndpoint interface {
|
||||||
|
// HandleRequest - HandleRequest processes an incoming HTTP request and returns a response.
|
||||||
|
HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||||
|
} // Internal implementation holders
|
||||||
|
var (
|
||||||
|
handleRequestImpl func(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register registers a httpendpoint implementation.
|
||||||
|
// All methods are required.
|
||||||
|
func Register(impl HTTPEndpoint) {
|
||||||
|
handleRequestImpl = impl.HandleRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||||
|
// The host recognizes this and skips the plugin gracefully.
|
||||||
|
const NotImplementedCode int32 = -2
|
||||||
|
|
||||||
|
//go:wasmexport nd_http_handle_request
|
||||||
|
func _NdHttpHandleRequest() int32 {
|
||||||
|
if handleRequestImpl == nil {
|
||||||
|
// Return standard code - host will skip this plugin gracefully
|
||||||
|
return NotImplementedCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||||
|
raw := pdk.Input()
|
||||||
|
if len(raw) < 4 {
|
||||||
|
pdk.SetErrorString("malformed input frame")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
jsonLen := binary.BigEndian.Uint32(raw[:4])
|
||||||
|
if uint32(len(raw)-4) < jsonLen {
|
||||||
|
pdk.SetErrorString("invalid json length in input frame")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
var input HTTPHandleRequest
|
||||||
|
if err := json.Unmarshal(raw[4:4+jsonLen], &input); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
input.Body = raw[4+jsonLen:]
|
||||||
|
|
||||||
|
output, err := handleRequestImpl(input)
|
||||||
|
if err != nil {
|
||||||
|
// Error frame: [0x01][UTF-8 error message]
|
||||||
|
errMsg := []byte(err.Error())
|
||||||
|
errFrame := make([]byte, 1+len(errMsg))
|
||||||
|
errFrame[0] = 0x01
|
||||||
|
copy(errFrame[1:], errMsg)
|
||||||
|
pdk.Output(errFrame)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||||
|
jsonBytes, _ := json.Marshal(output)
|
||||||
|
rawBytes := output.Body
|
||||||
|
frame := make([]byte, 1+4+len(jsonBytes)+len(rawBytes))
|
||||||
|
frame[0] = 0x00
|
||||||
|
binary.BigEndian.PutUint32(frame[1:5], uint32(len(jsonBytes)))
|
||||||
|
copy(frame[5:5+len(jsonBytes)], jsonBytes)
|
||||||
|
copy(frame[5+len(jsonBytes):], rawBytes)
|
||||||
|
pdk.Output(frame)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
65
plugins/pdk/go/httpendpoint/httpendpoint_stub.go
Normal file
65
plugins/pdk/go/httpendpoint/httpendpoint_stub.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Code generated by ndpgen. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// This file provides stub implementations for non-WASM platforms.
|
||||||
|
// It allows Go plugins to compile and run tests outside of WASM,
|
||||||
|
// but the actual functionality is only available in WASM builds.
|
||||||
|
//
|
||||||
|
//go:build !wasip1
|
||||||
|
|
||||||
|
package httpendpoint
|
||||||
|
|
||||||
|
// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||||
|
type HTTPHandleRequest struct {
|
||||||
|
// Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||||
|
Method string `json:"method"`
|
||||||
|
// Path is the request path relative to the plugin's base URL.
|
||||||
|
// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||||
|
// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||||
|
Path string `json:"path"`
|
||||||
|
// Query is the raw query string without the leading '?'.
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
// Headers contains the HTTP request headers.
|
||||||
|
Headers map[string][]string `json:"headers,omitempty"`
|
||||||
|
// Body is the request body content.
|
||||||
|
Body []byte `json:"-"`
|
||||||
|
// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||||
|
User *HTTPUser `json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||||
|
type HTTPHandleResponse struct {
|
||||||
|
// Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||||
|
Status int32 `json:"status,omitempty"`
|
||||||
|
// Headers contains the HTTP response headers to set.
|
||||||
|
Headers map[string][]string `json:"headers,omitempty"`
|
||||||
|
// Body is the response body content.
|
||||||
|
Body []byte `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPUser contains authenticated user information passed to the plugin.
|
||||||
|
type HTTPUser struct {
|
||||||
|
// ID is the internal Navidrome user ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Username is the user's login name.
|
||||||
|
Username string `json:"username"`
|
||||||
|
// Name is the user's display name.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// IsAdmin indicates whether the user has admin privileges.
|
||||||
|
IsAdmin bool `json:"isAdmin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPEndpoint requires all methods to be implemented.
|
||||||
|
// HTTPEndpoint allows plugins to handle incoming HTTP requests.
|
||||||
|
// Plugins that declare the 'endpoints' permission must implement this capability.
|
||||||
|
// The host dispatches incoming HTTP requests to the plugin's HandleRequest function.
|
||||||
|
type HTTPEndpoint interface {
|
||||||
|
// HandleRequest - HandleRequest processes an incoming HTTP request and returns a response.
|
||||||
|
HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||||
|
const NotImplementedCode int32 = -2
|
||||||
|
|
||||||
|
// Register is a no-op on non-WASM platforms.
|
||||||
|
// This stub allows code to compile outside of WASM.
|
||||||
|
func Register(_ HTTPEndpoint) {}
|
||||||
156
plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs
Normal file
156
plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// Code generated by ndpgen. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// This file contains export wrappers for the HTTPEndpoint capability.
|
||||||
|
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
// Helper functions for skip_serializing_if with numeric types
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
|
||||||
|
/// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HTTPHandleRequest {
|
||||||
|
/// Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||||
|
#[serde(default)]
|
||||||
|
pub method: String,
|
||||||
|
/// Path is the request path relative to the plugin's base URL.
|
||||||
|
/// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||||
|
/// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: String,
|
||||||
|
/// Query is the raw query string without the leading '?'.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub query: String,
|
||||||
|
/// Headers contains the HTTP request headers.
|
||||||
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub headers: std::collections::HashMap<String, Vec<String>>,
|
||||||
|
/// Body is the request body content.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
/// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub user: Option<HTTPUser>,
|
||||||
|
}
|
||||||
|
/// HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HTTPHandleResponse {
|
||||||
|
/// Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||||
|
#[serde(default, skip_serializing_if = "is_zero_i32")]
|
||||||
|
pub status: i32,
|
||||||
|
/// Headers contains the HTTP response headers to set.
|
||||||
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub headers: std::collections::HashMap<String, Vec<String>>,
|
||||||
|
/// Body is the response body content.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
}
|
||||||
|
/// HTTPUser contains authenticated user information passed to the plugin.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HTTPUser {
|
||||||
|
/// ID is the internal Navidrome user ID.
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: String,
|
||||||
|
/// Username is the user's login name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: String,
|
||||||
|
/// Name is the user's display name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
/// IsAdmin indicates whether the user has admin privileges.
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error represents an error from a capability method.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Error {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self { message: message.into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTPEndpoint requires all methods to be implemented.
|
||||||
|
/// HTTPEndpoint allows plugins to handle incoming HTTP requests.
|
||||||
|
/// Plugins that declare the 'endpoints' permission must implement this capability.
|
||||||
|
/// The host dispatches incoming HTTP requests to the plugin's HandleRequest function.
|
||||||
|
pub trait HTTPEndpoint {
|
||||||
|
/// HandleRequest - HandleRequest processes an incoming HTTP request and returns a response.
|
||||||
|
fn handle_request(&self, req: HTTPHandleRequest) -> Result<HTTPHandleResponse, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register all exports for the HTTPEndpoint capability.
|
||||||
|
/// This macro generates the WASM export functions for all trait methods.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! register_httpendpoint {
|
||||||
|
($plugin_type:ty) => {
|
||||||
|
#[extism_pdk::plugin_fn]
|
||||||
|
pub fn nd_http_handle_request(
|
||||||
|
_raw_input: extism_pdk::Raw<Vec<u8>>
|
||||||
|
) -> extism_pdk::FnResult<extism_pdk::Raw<Vec<u8>>> {
|
||||||
|
let plugin = <$plugin_type>::default();
|
||||||
|
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||||
|
let raw_bytes = _raw_input.0;
|
||||||
|
if raw_bytes.len() < 4 {
|
||||||
|
let mut err_frame = vec![0x01u8];
|
||||||
|
err_frame.extend_from_slice(b"malformed input frame");
|
||||||
|
return Ok(extism_pdk::Raw(err_frame));
|
||||||
|
}
|
||||||
|
let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize;
|
||||||
|
if json_len > raw_bytes.len() - 4 {
|
||||||
|
let mut err_frame = vec![0x01u8];
|
||||||
|
err_frame.extend_from_slice(b"invalid json length in input frame");
|
||||||
|
return Ok(extism_pdk::Raw(err_frame));
|
||||||
|
}
|
||||||
|
let mut req: $crate::httpendpoint::HTTPHandleRequest = serde_json::from_slice(&raw_bytes[4..4+json_len])
|
||||||
|
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||||
|
req.body = raw_bytes[4+json_len..].to_vec();
|
||||||
|
match $crate::httpendpoint::HTTPEndpoint::handle_request(&plugin, req) {
|
||||||
|
Ok(output) => {
|
||||||
|
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||||
|
let json_bytes = serde_json::to_vec(&output)
|
||||||
|
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||||
|
let raw_field = &output.body;
|
||||||
|
let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len());
|
||||||
|
frame.push(0x00);
|
||||||
|
frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes());
|
||||||
|
frame.extend_from_slice(&json_bytes);
|
||||||
|
frame.extend_from_slice(raw_field);
|
||||||
|
Ok(extism_pdk::Raw(frame))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Error frame: [0x01][UTF-8 error message]
|
||||||
|
let mut err_frame = vec![0x01u8];
|
||||||
|
err_frame.extend_from_slice(e.message.as_bytes());
|
||||||
|
Ok(extism_pdk::Raw(err_frame))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
//! This crate provides type definitions, traits, and registration macros
|
//! This crate provides type definitions, traits, and registration macros
|
||||||
//! for implementing Navidrome plugin capabilities in Rust.
|
//! for implementing Navidrome plugin capabilities in Rust.
|
||||||
|
|
||||||
|
pub mod httpendpoint;
|
||||||
pub mod lifecycle;
|
pub mod lifecycle;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
|
|||||||
@@ -33,11 +33,8 @@ func init() {
|
|||||||
// ScrobblerPlugin is an adapter that wraps an Extism plugin and implements
|
// ScrobblerPlugin is an adapter that wraps an Extism plugin and implements
|
||||||
// the scrobbler.Scrobbler interface for scrobbling to external services.
|
// the scrobbler.Scrobbler interface for scrobbling to external services.
|
||||||
type ScrobblerPlugin struct {
|
type ScrobblerPlugin struct {
|
||||||
name string
|
name string
|
||||||
plugin *plugin
|
plugin *plugin
|
||||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
|
||||||
allUsers bool // If true, plugin can access all users
|
|
||||||
userIDMap map[string]struct{} // Cached map for fast lookups
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsAuthorized checks if the user is authorized with this scrobbler.
|
// IsAuthorized checks if the user is authorized with this scrobbler.
|
||||||
@@ -45,7 +42,7 @@ type ScrobblerPlugin struct {
|
|||||||
// then delegates to the plugin for service-specific authorization.
|
// then delegates to the plugin for service-specific authorization.
|
||||||
func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
|
func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
|
||||||
// First check server-side authorization based on plugin configuration
|
// First check server-side authorization based on plugin configuration
|
||||||
if !s.isUserAllowed(userId) {
|
if !s.plugin.userAccess.IsAllowed(userId) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,18 +60,6 @@ func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// isUserAllowed checks if the given user ID is allowed to use this plugin.
|
|
||||||
func (s *ScrobblerPlugin) isUserAllowed(userId string) bool {
|
|
||||||
if s.allUsers {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len(s.allowedUserIDs) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, ok := s.userIDMap[userId]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// NowPlaying sends a now playing notification to the scrobbler
|
// NowPlaying sends a now playing notification to the scrobbler
|
||||||
func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||||
username := getUsernameFromContext(ctx)
|
username := getUsernameFromContext(ctx)
|
||||||
|
|||||||
@@ -71,41 +71,6 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("isUserAllowed", func() {
|
|
||||||
It("returns true when allUsers is true", func() {
|
|
||||||
sp := &ScrobblerPlugin{allUsers: true}
|
|
||||||
Expect(sp.isUserAllowed("any-user")).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns false when allowedUserIDs is empty and allUsers is false", func() {
|
|
||||||
sp := &ScrobblerPlugin{allUsers: false, allowedUserIDs: []string{}}
|
|
||||||
Expect(sp.isUserAllowed("user-1")).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns false when allowedUserIDs is nil and allUsers is false", func() {
|
|
||||||
sp := &ScrobblerPlugin{allUsers: false}
|
|
||||||
Expect(sp.isUserAllowed("user-1")).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns true when user is in allowedUserIDs", func() {
|
|
||||||
sp := &ScrobblerPlugin{
|
|
||||||
allUsers: false,
|
|
||||||
allowedUserIDs: []string{"user-1", "user-2"},
|
|
||||||
userIDMap: map[string]struct{}{"user-1": {}, "user-2": {}},
|
|
||||||
}
|
|
||||||
Expect(sp.isUserAllowed("user-1")).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns false when user is not in allowedUserIDs", func() {
|
|
||||||
sp := &ScrobblerPlugin{
|
|
||||||
allUsers: false,
|
|
||||||
allowedUserIDs: []string{"user-1", "user-2"},
|
|
||||||
userIDMap: map[string]struct{}{"user-1": {}, "user-2": {}},
|
|
||||||
}
|
|
||||||
Expect(sp.isUserAllowed("user-3")).To(BeFalse())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("NowPlaying", func() {
|
Describe("NowPlaying", func() {
|
||||||
It("successfully calls the plugin", func() {
|
It("successfully calls the plugin", func() {
|
||||||
track := &model.MediaFile{
|
track := &model.MediaFile{
|
||||||
|
|||||||
16
plugins/testdata/test-http-endpoint-native/go.mod
vendored
Normal file
16
plugins/testdata/test-http-endpoint-native/go.mod
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module test-http-endpoint-native
|
||||||
|
|
||||||
|
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-http-endpoint-native/go.sum
vendored
Normal file
14
plugins/testdata/test-http-endpoint-native/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=
|
||||||
61
plugins/testdata/test-http-endpoint-native/main.go
vendored
Normal file
61
plugins/testdata/test-http-endpoint-native/main.go
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Test plugin for native auth (JWT) HTTP endpoint integration tests.
|
||||||
|
// Build with: tinygo build -o ../test-http-endpoint-native.wasm -target wasip1 -buildmode=c-shared .
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/httpendpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
httpendpoint.Register(&testNativeEndpoint{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type testNativeEndpoint struct{}
|
||||||
|
|
||||||
|
func (t *testNativeEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpendpoint.HTTPHandleResponse, error) {
|
||||||
|
switch req.Path {
|
||||||
|
case "/hello":
|
||||||
|
return httpendpoint.HTTPHandleResponse{
|
||||||
|
Status: 200,
|
||||||
|
Headers: map[string][]string{
|
||||||
|
"Content-Type": {"text/plain"},
|
||||||
|
},
|
||||||
|
Body: []byte("Hello from native auth plugin!"),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "/echo":
|
||||||
|
// Echo back the request as JSON
|
||||||
|
data, _ := json.Marshal(map[string]any{
|
||||||
|
"method": req.Method,
|
||||||
|
"path": req.Path,
|
||||||
|
"query": req.Query,
|
||||||
|
"body": string(req.Body),
|
||||||
|
"hasUser": req.User != nil,
|
||||||
|
"username": userName(req.User),
|
||||||
|
})
|
||||||
|
return httpendpoint.HTTPHandleResponse{
|
||||||
|
Status: 200,
|
||||||
|
Headers: map[string][]string{
|
||||||
|
"Content-Type": {"application/json"},
|
||||||
|
},
|
||||||
|
Body: data,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return httpendpoint.HTTPHandleResponse{
|
||||||
|
Status: 404,
|
||||||
|
Body: []byte("Not found: " + req.Path),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func userName(u *httpendpoint.HTTPUser) string {
|
||||||
|
if u == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {}
|
||||||
16
plugins/testdata/test-http-endpoint-native/manifest.json
vendored
Normal file
16
plugins/testdata/test-http-endpoint-native/manifest.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "Test HTTP Endpoint Native Plugin",
|
||||||
|
"author": "Navidrome Test",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test plugin for native (JWT) HTTP endpoint integration testing",
|
||||||
|
"permissions": {
|
||||||
|
"endpoints": {
|
||||||
|
"auth": "native",
|
||||||
|
"paths": ["/hello", "/echo"],
|
||||||
|
"reason": "Testing native auth HTTP endpoint handling"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"reason": "Authenticated endpoints require user access"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
plugins/testdata/test-http-endpoint-public/go.mod
vendored
Normal file
16
plugins/testdata/test-http-endpoint-public/go.mod
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module test-http-endpoint-public
|
||||||
|
|
||||||
|
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-http-endpoint-public/go.sum
vendored
Normal file
14
plugins/testdata/test-http-endpoint-public/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=
|
||||||
45
plugins/testdata/test-http-endpoint-public/main.go
vendored
Normal file
45
plugins/testdata/test-http-endpoint-public/main.go
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Test plugin for public (unauthenticated) HTTP endpoint integration tests.
|
||||||
|
// Build with: tinygo build -o ../test-http-endpoint-public.wasm -target wasip1 -buildmode=c-shared .
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/httpendpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
httpendpoint.Register(&testPublicEndpoint{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type testPublicEndpoint struct{}
|
||||||
|
|
||||||
|
func (t *testPublicEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpendpoint.HTTPHandleResponse, error) {
|
||||||
|
switch req.Path {
|
||||||
|
case "/webhook":
|
||||||
|
return httpendpoint.HTTPHandleResponse{
|
||||||
|
Status: 200,
|
||||||
|
Headers: map[string][]string{
|
||||||
|
"Content-Type": {"text/plain"},
|
||||||
|
},
|
||||||
|
Body: []byte("webhook received"),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "/check-no-user":
|
||||||
|
// Verify that no user info is provided for public endpoints
|
||||||
|
hasUser := "false"
|
||||||
|
if req.User != nil {
|
||||||
|
hasUser = "true"
|
||||||
|
}
|
||||||
|
return httpendpoint.HTTPHandleResponse{
|
||||||
|
Status: 200,
|
||||||
|
Body: []byte("hasUser=" + hasUser),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return httpendpoint.HTTPHandleResponse{
|
||||||
|
Status: 404,
|
||||||
|
Body: []byte("Not found: " + req.Path),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {}
|
||||||
13
plugins/testdata/test-http-endpoint-public/manifest.json
vendored
Normal file
13
plugins/testdata/test-http-endpoint-public/manifest.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "Test HTTP Endpoint Public Plugin",
|
||||||
|
"author": "Navidrome Test",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test plugin for public (unauthenticated) HTTP endpoint integration testing",
|
||||||
|
"permissions": {
|
||||||
|
"endpoints": {
|
||||||
|
"auth": "none",
|
||||||
|
"paths": ["/webhook"],
|
||||||
|
"reason": "Testing public HTTP endpoints"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
plugins/testdata/test-http-endpoint/go.mod
vendored
Normal file
16
plugins/testdata/test-http-endpoint/go.mod
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module test-http-endpoint
|
||||||
|
|
||||||
|
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-http-endpoint/go.sum
vendored
Normal file
14
plugins/testdata/test-http-endpoint/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=
|
||||||
77
plugins/testdata/test-http-endpoint/main.go
vendored
Normal file
77
plugins/testdata/test-http-endpoint/main.go
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Test plugin for HTTP endpoint integration tests.
|
||||||
|
// Build with: tinygo build -o ../test-http-endpoint.wasm -target wasip1 -buildmode=c-shared .
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/httpendpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
httpendpoint.Register(&testEndpoint{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type testEndpoint struct{}
|
||||||
|
|
||||||
|
func (t *testEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpendpoint.HTTPHandleResponse, error) {
|
||||||
|
switch req.Path {
|
||||||
|
case "/hello":
|
||||||
|
return httpendpoint.HTTPHandleResponse{
|
||||||
|
Status: 200,
|
||||||
|
Headers: map[string][]string{
|
||||||
|
"Content-Type": {"text/plain"},
|
||||||
|
},
|
||||||
|
Body: []byte("Hello from plugin!"),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "/echo":
|
||||||
|
// Echo back the request as JSON
|
||||||
|
data, _ := json.Marshal(map[string]any{
|
||||||
|
"method": req.Method,
|
||||||
|
"path": req.Path,
|
||||||
|
"query": req.Query,
|
||||||
|
"body": string(req.Body),
|
||||||
|
"hasUser": req.User != nil,
|
||||||
|
"username": userName(req.User),
|
||||||
|
})
|
||||||
|
return httpendpoint.HTTPHandleResponse{
|
||||||
|
Status: 200,
|
||||||
|
Headers: map[string][]string{
|
||||||
|
"Content-Type": {"application/json"},
|
||||||
|
},
|
||||||
|
Body: data,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "/binary":
|
||||||
|
// Return raw binary data (PNG header)
|
||||||
|
return httpendpoint.HTTPHandleResponse{
|
||||||
|
Status: 200,
|
||||||
|
Headers: map[string][]string{
|
||||||
|
"Content-Type": {"image/png"},
|
||||||
|
},
|
||||||
|
Body: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "/error":
|
||||||
|
return httpendpoint.HTTPHandleResponse{
|
||||||
|
Status: 500,
|
||||||
|
Body: []byte("Something went wrong"),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return httpendpoint.HTTPHandleResponse{
|
||||||
|
Status: 404,
|
||||||
|
Body: []byte("Not found: " + req.Path),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func userName(u *httpendpoint.HTTPUser) string {
|
||||||
|
if u == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {}
|
||||||
16
plugins/testdata/test-http-endpoint/manifest.json
vendored
Normal file
16
plugins/testdata/test-http-endpoint/manifest.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "Test HTTP Endpoint Plugin",
|
||||||
|
"author": "Navidrome Test",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test plugin for HTTP endpoint integration testing",
|
||||||
|
"permissions": {
|
||||||
|
"endpoints": {
|
||||||
|
"auth": "subsonic",
|
||||||
|
"paths": ["/hello", "/echo"],
|
||||||
|
"reason": "Testing HTTP endpoint handling"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"reason": "Authenticated endpoints require user access"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
plugins/user_access.go
Normal file
35
plugins/user_access.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
// UserAccess encapsulates user authorization for a plugin,
|
||||||
|
// determining which users are allowed to interact with it.
|
||||||
|
type UserAccess struct {
|
||||||
|
allUsers bool
|
||||||
|
userIDMap map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserAccess creates a UserAccess from the plugin's configuration.
|
||||||
|
// If allUsers is true, all users are allowed regardless of the list.
|
||||||
|
func NewUserAccess(allUsers bool, userIDs []string) UserAccess {
|
||||||
|
userIDMap := make(map[string]struct{}, len(userIDs))
|
||||||
|
for _, id := range userIDs {
|
||||||
|
userIDMap[id] = struct{}{}
|
||||||
|
}
|
||||||
|
return UserAccess{
|
||||||
|
allUsers: allUsers,
|
||||||
|
userIDMap: userIDMap,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAllowed checks if the given user ID is permitted.
|
||||||
|
func (ua UserAccess) IsAllowed(userID string) bool {
|
||||||
|
if ua.allUsers {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, ok := ua.userIDMap[userID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasConfiguredUsers reports whether any specific user IDs have been configured.
|
||||||
|
func (ua UserAccess) HasConfiguredUsers() bool {
|
||||||
|
return ua.allUsers || len(ua.userIDMap) > 0
|
||||||
|
}
|
||||||
64
plugins/user_access_test.go
Normal file
64
plugins/user_access_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("UserAccess", func() {
|
||||||
|
Describe("IsAllowed", func() {
|
||||||
|
It("returns true when allUsers is true", func() {
|
||||||
|
ua := NewUserAccess(true, nil)
|
||||||
|
Expect(ua.IsAllowed("any-user")).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns true when allUsers is true even with an explicit list", func() {
|
||||||
|
ua := NewUserAccess(true, []string{"user-1"})
|
||||||
|
Expect(ua.IsAllowed("other-user")).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false when userIDs is empty", func() {
|
||||||
|
ua := NewUserAccess(false, []string{})
|
||||||
|
Expect(ua.IsAllowed("user-1")).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false when userIDs is nil", func() {
|
||||||
|
ua := NewUserAccess(false, nil)
|
||||||
|
Expect(ua.IsAllowed("user-1")).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns true when user is in the list", func() {
|
||||||
|
ua := NewUserAccess(false, []string{"user-1", "user-2"})
|
||||||
|
Expect(ua.IsAllowed("user-1")).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false when user is not in the list", func() {
|
||||||
|
ua := NewUserAccess(false, []string{"user-1", "user-2"})
|
||||||
|
Expect(ua.IsAllowed("user-3")).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("HasConfiguredUsers", func() {
|
||||||
|
It("returns true when allUsers is true", func() {
|
||||||
|
ua := NewUserAccess(true, nil)
|
||||||
|
Expect(ua.HasConfiguredUsers()).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns true when specific users are configured", func() {
|
||||||
|
ua := NewUserAccess(false, []string{"user-1"})
|
||||||
|
Expect(ua.HasConfiguredUsers()).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false when no users are configured", func() {
|
||||||
|
ua := NewUserAccess(false, nil)
|
||||||
|
Expect(ua.HasConfiguredUsers()).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false when user list is empty", func() {
|
||||||
|
ua := NewUserAccess(false, []string{})
|
||||||
|
Expect(ua.HasConfiguredUsers()).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,24 +25,32 @@ import (
|
|||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||||
. "github.com/navidrome/navidrome/utils/gg"
|
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// mergeFormIntoQuery parses form data (both URL query params and POST body)
|
||||||
|
// and writes all values back into r.URL.RawQuery. This is needed because
|
||||||
|
// some Subsonic clients send parameters as form fields instead of query params.
|
||||||
|
// This support the OpenSubsonic `formPost` extension
|
||||||
|
func mergeFormIntoQuery(r *http.Request) error {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for key, values := range r.Form {
|
||||||
|
for _, v := range values {
|
||||||
|
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.URL.RawQuery = strings.Join(parts, "&")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func postFormToQueryParams(next http.Handler) http.Handler {
|
func postFormToQueryParams(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
err := r.ParseForm()
|
if err := mergeFormIntoQuery(r); err != nil {
|
||||||
if err != nil {
|
|
||||||
sendError(w, r, newError(responses.ErrorGeneric, err.Error()))
|
sendError(w, r, newError(responses.ErrorGeneric, err.Error()))
|
||||||
}
|
}
|
||||||
var parts []string
|
|
||||||
for key, values := range r.Form {
|
|
||||||
for _, v := range values {
|
|
||||||
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.URL.RawQuery = strings.Join(parts, "&")
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -95,54 +103,64 @@ func checkRequiredParameters(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authenticateRequest validates the authentication credentials in an HTTP request and returns
|
||||||
|
// the authenticated user. It supports internal auth, reverse proxy auth, and Subsonic classic
|
||||||
|
// auth (username + password/token/salt/jwt query params).
|
||||||
|
//
|
||||||
|
// Callers should handle specific error types as needed:
|
||||||
|
// - context.Canceled: request was canceled during authentication
|
||||||
|
// - model.ErrNotFound: username not found in database
|
||||||
|
// - model.ErrInvalidAuth: invalid credentials (wrong password, token, etc.)
|
||||||
|
func authenticateRequest(ds model.DataStore, r *http.Request) (*model.User, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Check internal auth or reverse proxy auth first
|
||||||
|
username, _ := fromInternalOrProxyAuth(r)
|
||||||
|
if username != "" {
|
||||||
|
return ds.User(ctx).FindByUsername(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Subsonic classic auth (query params)
|
||||||
|
p := req.Params(r)
|
||||||
|
username, _ = p.String("u")
|
||||||
|
if username == "" {
|
||||||
|
return nil, model.ErrInvalidAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
pass, _ := p.String("p")
|
||||||
|
token, _ := p.String("t")
|
||||||
|
salt, _ := p.String("s")
|
||||||
|
jwt, _ := p.String("jwt")
|
||||||
|
|
||||||
|
usr, err := ds.User(ctx).FindByUsernameWithPassword(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateCredentials(usr, pass, token, salt, jwt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return usr, nil
|
||||||
|
}
|
||||||
|
|
||||||
func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
var usr *model.User
|
usr, err := authenticateRequest(ds, r)
|
||||||
var err error
|
|
||||||
|
|
||||||
username, isInternalAuth := fromInternalOrProxyAuth(r)
|
|
||||||
if username != "" {
|
|
||||||
authType := If(isInternalAuth, "internal", "reverse-proxy")
|
|
||||||
usr, err = ds.User(ctx).FindByUsername(username)
|
|
||||||
if errors.Is(err, context.Canceled) {
|
|
||||||
log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
|
||||||
log.Warn(ctx, "API: Invalid login", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
|
||||||
} else if err != nil {
|
|
||||||
log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
p := req.Params(r)
|
|
||||||
username, _ := p.String("u")
|
|
||||||
pass, _ := p.String("p")
|
|
||||||
token, _ := p.String("t")
|
|
||||||
salt, _ := p.String("s")
|
|
||||||
jwt, _ := p.String("jwt")
|
|
||||||
|
|
||||||
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
|
|
||||||
if errors.Is(err, context.Canceled) {
|
|
||||||
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, model.ErrNotFound):
|
|
||||||
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
|
||||||
case err != nil:
|
|
||||||
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
|
||||||
default:
|
|
||||||
err = validateCredentials(usr, pass, token, salt, jwt)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
username, _ := request.UsernameFrom(ctx)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
log.Debug(ctx, "API: Request canceled when authenticating", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||||
|
return
|
||||||
|
case errors.Is(err, model.ErrNotFound), errors.Is(err, model.ErrInvalidAuth):
|
||||||
|
log.Warn(ctx, "API: Invalid login", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||||
|
default:
|
||||||
|
log.Error(ctx, "API: Error authenticating", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||||
|
}
|
||||||
sendError(w, r, newError(responses.ErrorAuthenticationFail))
|
sendError(w, r, newError(responses.ErrorAuthenticationFail))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,6 +171,19 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateAuth validates Subsonic authentication from an HTTP request and returns the authenticated user.
|
||||||
|
// Unlike the authenticate middleware, this function does not write any HTTP response, making it suitable
|
||||||
|
// for use by external consumers (e.g., plugin endpoints) that need Subsonic auth but want to handle
|
||||||
|
// errors themselves.
|
||||||
|
func ValidateAuth(ds model.DataStore, r *http.Request) (*model.User, error) {
|
||||||
|
// Parse form data into query params (same as postFormToQueryParams middleware,
|
||||||
|
// which is not in the call chain when ValidateAuth is used directly)
|
||||||
|
if err := mergeFormIntoQuery(r); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing form: %w", err)
|
||||||
|
}
|
||||||
|
return authenticateRequest(ds, r)
|
||||||
|
}
|
||||||
|
|
||||||
func validateCredentials(user *model.User, pass, token, salt, jwt string) error {
|
func validateCredentials(user *model.User, pass, token, salt, jwt string) error {
|
||||||
valid := false
|
valid := false
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user