Compare commits

..

19 Commits

Author SHA1 Message Date
Deluan
6c260db60c fix(plugins): use size cap instead of wraparound check for CodeQL overflow warning
Check individual slice sizes against a 128 MiB cap before the addition,
so CodeQL can statically verify the sum cannot overflow.
2026-02-13 16:03:52 -05:00
Deluan
fc113d1dc6 fix(plugins): guard against integer overflow in callPluginFunctionRaw frame allocation
Add overflow check before allocating the input frame buffer to prevent
potential integer wraparound on 32-bit platforms (flagged by github-advanced-security).
2026-02-13 15:55:43 -05:00
Deluan
425fe862ba refactor(plugins): enhance user authorization handling and streamline form data processing
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-13 15:47:51 -05:00
Deluan
b1a51f9bbe feat(plugins): add raw binary framing support for HTTP endpoint requests and responses
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-13 15:20:55 -05:00
Deluan
9a004fd043 fix(plugins): correct Rust codegen serde attributes and harden endpoint responses
Fix two issues from PR #5045 review. The Rust code generator was
producing incorrect skip_serializing_if attributes: map types incorrectly
used Option::is_none instead of HashMap::is_empty, and the bare int type
for HTTPHandleResponse.Status fell through to the default Option::is_none
case. The map fix is in skipSerializingFunc; the int issue is fixed at the
source by changing Status from int to int32 (HTTP status codes always fit
in int32, and this avoids platform-dependent int sizing on i386 vs amd64).

Additionally, plugin HTTP responses now include forced security headers
(X-Content-Type-Options: nosniff and a restrictive Content-Security-Policy
with sandbox) to prevent XSS from compromised plugins serving HTML+JS on
the same origin as Navidrome.
2026-02-13 13:43:41 -05:00
Deluan
5c52bbb130 refactor(auth): simplify authentication logic and improve error handling
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-13 12:57:30 -05:00
Deluan
b0f91715b9 refactor(plugins): streamline user access management in plugin services
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-13 12:46:36 -05:00
Deluan
9f7b6870ac feat(plugins): implement HTTP endpoint capability for plugins
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-13 11:55:10 -05:00
Paul Becker
f00af7f983 feat(ui): add Dracula theme (#5023)
Signed-off-by: Paul Becker <p@becker.kiwi>
2026-02-12 16:42:34 -05:00
Deluan Quintão
875ffc2b78 fix(ui): update Danish, Portuguese (BR) translations from POEditor (#5039)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2026-02-12 16:38:57 -05:00
ChekeredList71
885334c819 fix(ui): update Hungarian translation (#5041)
* new strings added

* "empty" solved

---------

Co-authored-by: ChekeredList71 <asd@asd.com>
2026-02-12 16:36:05 -05:00
Deluan
ff86b9f2b9 ci: add GitHub Actions workflow for pushing translations to POEditor 2026-02-12 16:32:58 -05:00
Xabi
13d3d510f5 fix(ui): update Basque localisation (#5038)
* Update Basque localisation

Added missing strings and a couple of improvements.

* Update resources/i18n/eu.json

typo

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-12 15:52:37 -05:00
fxj368
656009e5f8 fix(i18n) update Chinese Simplified translation (#5025)
* Update Chinese Simplified translation

* fix some structural issue and an incorrect translation
2026-02-12 15:49:20 -05:00
Deluan
06b3a1f33e fix(insights): update HasCustomPID logic to use default constants
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-12 14:33:25 -05:00
Kendall Garner
0f4e8376cb feat(ui): add download config toml link, disable copy when clipboard not available (#5035) 2026-02-12 10:54:04 -05:00
Deluan
199cde4109 fix: upgrade go-taglib to latest version
Updated the go-taglib dependency to pick up the latest bug fixes from
the forked repository. This resolves an issue reported in #5037.
2026-02-12 10:12:04 -05:00
Deluan
897de02a84 docs: documents how subsonic e2e tests are structured 2026-02-11 22:49:41 -05:00
Deluan
7ee56fe3bf chore: update golangci-lint version to v2.9.0 in Makefile
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-11 08:31:51 -05:00
68 changed files with 4322 additions and 934 deletions

138
.github/workflows/push-translations.sh vendored Executable file
View File

@@ -0,0 +1,138 @@
#!/bin/sh
set -e
I18N_DIR=resources/i18n
# Normalize JSON for deterministic comparison:
# remove empty/null attributes, sort keys alphabetically
process_json() {
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
}
# Get list of all languages configured in the POEditor project
get_language_list() {
curl -s -X POST https://api.poeditor.com/v2/languages/list \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}"
}
# Extract language name from the language list JSON given a language code
get_language_name() {
lang_code="$1"
lang_list="$2"
echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
}
# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
get_lang_code() {
filepath="$1"
filename=$(basename "$filepath")
echo "${filename%.*}"
}
# Export the current translation for a language from POEditor (v2 API)
export_language() {
lang_code="$1"
response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$lang_code" \
-d type="key_value_json")
url=$(echo "$response" | jq -r '.result.url')
if [ -z "$url" ] || [ "$url" = "null" ]; then
echo "Failed to export $lang_code: $response" >&2
return 1
fi
echo "$url"
}
# Flatten nested JSON to POEditor languages/update format.
# POEditor uses term + context pairs, where:
# term = the leaf key name
# context = the parent path as "key1"."key2"."key3" (empty for root keys)
flatten_to_poeditor() {
jq -c '[paths(scalars) as $p |
{
"term": ($p | last | tostring),
"context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
"translation": {"content": getpath($p)}
}
]' "$1"
}
# Update translations for a language in POEditor via languages/update API
update_language() {
lang_code="$1"
file="$2"
flatten_to_poeditor "$file" > /tmp/poeditor_data.json
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$lang_code" \
--data-urlencode data@/tmp/poeditor_data.json)
rm -f /tmp/poeditor_data.json
status=$(echo "$response" | jq -r '.response.status')
if [ "$status" != "success" ]; then
echo "Failed to update $lang_code: $response" >&2
return 1
fi
parsed=$(echo "$response" | jq -r '.result.translations.parsed')
added=$(echo "$response" | jq -r '.result.translations.added')
updated=$(echo "$response" | jq -r '.result.translations.updated')
echo " Translations - parsed: $parsed, added: $added, updated: $updated"
}
# --- Main ---
if [ $# -eq 0 ]; then
echo "Usage: $0 <file1> [file2] ..."
echo "No files specified. Nothing to do."
exit 0
fi
lang_list=$(get_language_list)
upload_count=0
for file in "$@"; do
if [ ! -f "$file" ]; then
echo "Warning: File not found: $file, skipping"
continue
fi
lang_code=$(get_lang_code "$file")
lang_name=$(get_language_name "$lang_code" "$lang_list")
if [ -z "$lang_name" ]; then
echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
continue
fi
echo "Processing $lang_name ($lang_code)..."
# Export current state from POEditor
url=$(export_language "$lang_code")
curl -sSL "$url" -o poeditor_export.json
# Normalize both files for comparison
process_json "$file" > local_normalized.json
process_json poeditor_export.json > remote_normalized.json
# Compare normalized versions
if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
echo " No differences, skipping"
else
echo " Differences found, updating POEditor..."
update_language "$lang_code" "$file"
upload_count=$((upload_count + 1))
fi
rm -f poeditor_export.json local_normalized.json remote_normalized.json
done
echo ""
echo "Done. Updated $upload_count translation(s) in POEditor."

32
.github/workflows/push-translations.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: POEditor export
on:
push:
branches:
- master
paths:
- 'resources/i18n/*.json'
jobs:
push-translations:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2
- name: Detect changed translation files
id: changed
run: |
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
echo "Changed translation files: $CHANGED_FILES"
- name: Push translations to POEditor
if: ${{ steps.changed.outputs.files != '' }}
env:
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
run: |
.github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}

View File

@@ -20,7 +20,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-2
GOLANGCI_LINT_VERSION ?= v2.8.0
GOLANGCI_LINT_VERSION ?= v2.9.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")

View File

@@ -14,10 +14,13 @@ import (
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/scheduler"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/backgrounds"
"github.com/navidrome/navidrome/server/subsonic"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/sync/errgroup"
@@ -138,6 +141,13 @@ func startServer(ctx context.Context) func() error {
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
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)
}
}

View File

@@ -239,11 +239,13 @@ type inspectOptions struct {
}
type pluginsOptions struct {
Enabled bool
Folder string
CacheSize string
AutoReload bool
LogLevel string
Enabled bool
Folder string
CacheSize string
AutoReload bool
LogLevel string
EndpointRequestLimit int
EndpointRequestWindow time.Duration
}
type extAuthOptions struct {
@@ -671,6 +673,8 @@ func setViperDefaults() {
viper.SetDefault("plugins.enabled", true)
viper.SetDefault("plugins.cachesize", "200MB")
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
viper.SetDefault("devlogsourceline", false)

View File

@@ -36,11 +36,13 @@ const (
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"
URLPathUI = "/app"
URLPathNativeAPI = "/api"
URLPathSubsonicAPI = "/rest"
URLPathPublic = "/share"
URLPathPublicImages = URLPathPublic + "/img"
URLPathUI = "/app"
URLPathNativeAPI = "/api"
URLPathSubsonicAPI = "/rest"
URLPathPluginEndpoints = "/ext"
URLPathPluginSubsonicEndpoints = "/rest/ext"
URLPathPublic = "/share"
URLPathPublicImages = URLPathPublic + "/img"
// DefaultUILoginBackgroundURL uses Navidrome curated background images collection,
// available at https://unsplash.com/collections/20072696/navidrome

View File

@@ -220,7 +220,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
return data

2
go.mod
View File

@@ -7,7 +7,7 @@ replace (
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
// Fork to implement raw tags support
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e
)
require (

4
go.sum
View File

@@ -36,8 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0 h1:R8fMzz++cqdQ3DVjzrmAKmZFr2PT8vT8pQEfRzxms00=
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e h1:pwx3kmHzl1N28coJV2C1zfm2ZF0qkQcGX+Z6BvXteB4=
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=

View 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"`
}

View 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

View 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,
)
}

View File

@@ -364,6 +364,27 @@ func capabilityFuncMap(cap Capability) template.FuncMap {
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
"implVar": func(e Export) string { return e.ImplVarName() },
"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() },
"registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) },
"snakeCase": ToSnakeCase,
"rawFieldName": rawFieldName(cap),
"indent": func(spaces int, s string) string {
indent := strings.Repeat(" ", spaces)
lines := strings.Split(s, "\n")
@@ -560,9 +582,15 @@ func rustConstName(name string) string {
// skipSerializingFunc returns the appropriate skip_serializing_if function name.
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"
}
if strings.HasPrefix(goType, "map[") {
return "HashMap::is_empty"
}
switch goType {
case "string":
return "String::is_empty"

View File

@@ -1432,12 +1432,20 @@ type OnInitOutput struct {
var _ = Describe("Rust Generation", 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("*MyStruct")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("[]string")).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() {

View File

@@ -269,6 +269,7 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri
Name: name,
ExportName: annotation["name"],
Doc: doc,
Raw: annotation["raw"] == "true",
}
// Capability exports have exactly one input parameter (the struct type)

View File

@@ -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() {
It("should generate correct provider interface name", func() {
e := Export{Name: "GetArtistBiography"}

View File

@@ -9,6 +9,10 @@ package {{.Package}}
import (
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
{{- if .Capability.HasRawMethods}}
"encoding/binary"
"encoding/json"
{{- end}}
)
{{- /* Generate type alias definitions */ -}}
@@ -56,6 +60,7 @@ func (e {{$typeName}}) Error() string { return string(e) }
{{- end}}
{{- /* Generate struct definitions */ -}}
{{- $capability := .Capability}}
{{- range .Capability.Structs}}
{{- if .Doc}}
@@ -68,8 +73,12 @@ type {{.Name}} struct {
{{- if .Doc}}
{{formatDoc .Doc | indent 1}}
{{- end}}
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
{{.Name}} {{.Type}} `json:"-"`
{{- else}}
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
{{- end}}
{{- end}}
}
{{- end}}
@@ -172,6 +181,53 @@ func {{exportFunc .}}() int32 {
// Return standard code - host will skip this plugin gracefully
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}}
var input {{.Input.Type}}
@@ -216,6 +272,7 @@ func {{exportFunc .}}() int32 {
pdk.SetError(err)
return -1
}
{{- end}}
{{- end}}
return 0

View File

@@ -52,6 +52,7 @@ pub const {{rustConstName $v.Name}}: &'static str = {{$v.Value}};
{{- end}}
{{- /* Generate struct definitions */ -}}
{{- $capability := .Capability}}
{{- range .Capability.Structs}}
{{- if .Doc}}
@@ -66,13 +67,17 @@ pub struct {{.Name}} {
{{- if .Doc}}
{{rustDocComment .Doc | indent 4}}
{{- 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}}")]
pub {{rustFieldName .Name}}: {{fieldRustType .}},
{{- else}}
#[serde(default)]
{{- end}}
pub {{rustFieldName .Name}}: {{fieldRustType .}},
{{- end}}
{{- end}}
}
{{- end}}
@@ -124,6 +129,56 @@ pub trait {{agentName .Capability}} {
macro_rules! register_{{snakeCase .Package}} {
($plugin_type:ty) => {
{{- 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]
pub fn {{.ExportName}}(
{{- if .HasInput}}
@@ -146,6 +201,7 @@ macro_rules! register_{{snakeCase .Package}} {
{{- end}}
}
{{- end}}
{{- end}}
};
}
{{- else}}
@@ -171,6 +227,56 @@ pub trait {{providerInterface .}} {
#[macro_export]
macro_rules! {{registerMacroName .Name}} {
($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]
pub fn {{.ExportName}}(
{{- if .HasInput}}
@@ -192,6 +298,7 @@ macro_rules! {{registerMacroName .Name}} {
Ok(())
{{- end}}
}
{{- end}}
};
}
{{- end}}

View File

@@ -53,6 +53,7 @@ func (e {{$typeName}}) Error() string { return string(e) }
{{- end}}
{{- /* Generate struct definitions */ -}}
{{- $capability := .Capability}}
{{- range .Capability.Structs}}
{{- if .Doc}}
@@ -65,8 +66,12 @@ type {{.Name}} struct {
{{- if .Doc}}
{{formatDoc .Doc | indent 1}}
{{- end}}
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
{{.Name}} {{.Type}} `json:"-"`
{{- else}}
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
{{- end}}
{{- end}}
}
{{- end}}

View File

@@ -48,6 +48,16 @@ type ConstDef struct {
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.
func (c Capability) KnownStructs() map[string]bool {
result := make(map[string]bool)
@@ -64,6 +74,7 @@ type Export struct {
Input Param // Single input parameter (the struct type)
Output Param // Single output return value (the struct type)
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.

View File

@@ -54,6 +54,14 @@ type (
Nullable bool `yaml:"nullable,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.
@@ -206,7 +214,12 @@ func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema
for _, field := range st.Fields {
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 {
schema.Required = append(schema.Required, propName)
@@ -246,6 +259,12 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
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
if strings.HasPrefix(goType, "[]") {
elemType := goType[2:]
@@ -264,6 +283,55 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
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.
func addToMap[T any](node *yaml.Node, key string, value T) {
var valNode yaml.Node

View File

@@ -719,4 +719,139 @@ var _ = Describe("XTP Schema Generation", func() {
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"),
)
})
})

View File

@@ -26,27 +26,19 @@ const subsonicAPIVersion = "1.16.1"
// 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).
type subsonicAPIServiceImpl struct {
pluginID string
router SubsonicRouter
ds model.DataStore
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
allUsers bool // If true, plugin can access all users
userIDMap map[string]struct{}
pluginName string
router SubsonicRouter
ds model.DataStore
userAccess UserAccess
}
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, allowedUserIDs []string, allUsers bool) host.SubsonicAPIService {
userIDMap := make(map[string]struct{})
for _, id := range allowedUserIDs {
userIDMap[id] = struct{}{}
}
func newSubsonicAPIService(pluginName string, router SubsonicRouter, ds model.DataStore, userAccess UserAccess) host.SubsonicAPIService {
return &subsonicAPIServiceImpl{
pluginID: pluginID,
router: router,
ds: ds,
allowedUserIDs: allowedUserIDs,
allUsers: allUsers,
userIDMap: userIDMap,
pluginName: pluginName,
router: router,
ds: ds,
userAccess: userAccess,
}
}
@@ -74,12 +66,12 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string,
}
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
}
// 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
if setJSON {
query.Set("f", "json") // Response format
@@ -94,11 +86,8 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string,
RawQuery: query.Encode(),
}
// Create HTTP request with a fresh context to avoid Chi RouteContext pollution.
// Using http.NewRequest (instead of http.NewRequestWithContext) ensures the internal
// 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.
// Use http.NewRequest (not WithContext) to avoid inheriting Chi RouteContext;
// auth context is set explicitly below via request.WithInternalAuth.
httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
if err != nil {
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 {
// If allUsers is true, allow any user
if s.allUsers {
if s.userAccess.allUsers {
return nil
}
// Must have at least one allowed user ID configured
if len(s.allowedUserIDs) == 0 {
return fmt.Errorf("no users configured for plugin %s", s.pluginID)
// Must have at least one allowed user configured
if !s.userAccess.HasConfiguredUsers() {
return fmt.Errorf("no users configured for plugin %s", s.pluginName)
}
// 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
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)
}

View File

@@ -268,7 +268,7 @@ var _ = Describe("SubsonicAPIService", func() {
Context("with specific user IDs allowed", func() {
It("blocks users not in the allowed list", func() {
// 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()
_, err := service.Call(ctx, "/ping?u=testuser")
@@ -278,7 +278,7 @@ var _ = Describe("SubsonicAPIService", func() {
It("allows users in the allowed list", func() {
// 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()
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() {
// 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()
_, err := service.Call(ctx, "/ping?u=adminuser")
@@ -298,7 +298,7 @@ var _ = Describe("SubsonicAPIService", func() {
It("allows admin users when in allowed list", func() {
// 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()
response, err := service.Call(ctx, "/ping?u=adminuser")
@@ -309,7 +309,7 @@ var _ = Describe("SubsonicAPIService", func() {
Context("with allUsers=true", 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()
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() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=adminuser")
@@ -329,7 +329,7 @@ var _ = Describe("SubsonicAPIService", func() {
Context("with no users 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()
_, err := service.Call(ctx, "/ping?u=testuser")
@@ -338,7 +338,7 @@ var _ = Describe("SubsonicAPIService", 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()
_, err := service.Call(ctx, "/ping?u=testuser")
@@ -350,7 +350,7 @@ var _ = Describe("SubsonicAPIService", func() {
Describe("URL Handling", 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()
_, err := service.Call(ctx, "/ping")
@@ -359,7 +359,7 @@ var _ = Describe("SubsonicAPIService", 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()
_, err := service.Call(ctx, "://invalid")
@@ -368,7 +368,7 @@ var _ = Describe("SubsonicAPIService", 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()
_, err := service.Call(ctx, "/rest/ping.view?u=testuser")
@@ -381,7 +381,7 @@ var _ = Describe("SubsonicAPIService", func() {
Describe("CallRaw", 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()
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() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
@@ -403,7 +403,7 @@ var _ = Describe("SubsonicAPIService", 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()
_, _, 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() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt")
@@ -421,7 +421,7 @@ var _ = Describe("SubsonicAPIService", 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()
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser")
@@ -430,7 +430,7 @@ var _ = Describe("SubsonicAPIService", 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()
_, _, err := service.CallRaw(ctx, "://invalid")
@@ -441,7 +441,7 @@ var _ = Describe("SubsonicAPIService", func() {
Describe("Router Availability", 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()
_, err := service.Call(ctx, "/ping?u=testuser")

View File

@@ -9,16 +9,14 @@ import (
)
type usersServiceImpl struct {
ds model.DataStore
allowedUsers []string // User IDs this plugin can access
allUsers bool // If true, plugin can access all users
ds model.DataStore
userAccess UserAccess
}
func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) host.UsersService {
func newUsersService(ds model.DataStore, userAccess UserAccess) host.UsersService {
return &usersServiceImpl{
ds: ds,
allowedUsers: allowedUsers,
allUsers: allUsers,
ds: ds,
userAccess: userAccess,
}
}
@@ -28,17 +26,9 @@ func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) {
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
for _, u := range users {
// If allUsers is true, include all users
// Otherwise, only include users in the allowed list
if s.allUsers || allowedMap[u.ID] {
if s.userAccess.IsAllowed(u.ID) {
result = append(result, host.User{
UserName: u.UserName,
Name: u.Name,

View File

@@ -61,7 +61,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("with allUsers=true", func() {
BeforeEach(func() {
service = newUsersService(ds, nil, true)
service = newUsersService(ds, NewUserAccess(true, nil))
})
It("should return all users", func() {
@@ -100,7 +100,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("with specific allowed users", func() {
BeforeEach(func() {
// 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() {
@@ -119,7 +119,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("with empty allowed users and allUsers=false", func() {
BeforeEach(func() {
service = newUsersService(ds, []string{}, false)
service = newUsersService(ds, NewUserAccess(false, []string{}))
})
It("should return no users", func() {
@@ -132,7 +132,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("when datastore returns error", func() {
BeforeEach(func() {
mockUserRepo.Error = model.ErrNotFound
service = newUsersService(ds, nil, true)
service = newUsersService(ds, NewUserAccess(true, nil))
})
It("should propagate the error", func() {
@@ -170,7 +170,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("with allUsers=true", func() {
BeforeEach(func() {
service = newUsersService(ds, nil, true)
service = newUsersService(ds, NewUserAccess(true, nil))
})
It("should return only admin users", func() {
@@ -185,7 +185,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("with specific allowed users including admin", func() {
BeforeEach(func() {
// 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() {
@@ -199,7 +199,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("with specific allowed users excluding admin", func() {
BeforeEach(func() {
// 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() {
@@ -212,7 +212,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("when datastore returns error", func() {
BeforeEach(func() {
mockUserRepo.Error = model.ErrNotFound
service = newUsersService(ds, nil, true)
service = newUsersService(ds, NewUserAccess(true, nil))
})
It("should propagate the error", func() {

189
plugins/http_endpoint.go Normal file
View 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)
}
}
}

View 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))
})
})
})

View File

@@ -260,19 +260,10 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
return nil, false
}
// Build user ID map for fast lookups
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
// Create a new scrobbler adapter for this plugin
return &ScrobblerPlugin{
name: plugin.name,
plugin: plugin,
allowedUserIDs: plugin.allowedUserIDs,
allUsers: plugin.allUsers,
userIDMap: userIDMap,
name: plugin.name,
plugin: plugin,
}, true
}

View File

@@ -2,6 +2,7 @@ package plugins
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
@@ -60,39 +61,147 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcN
startCall := time.Now()
exit, output, err := p.CallWithContext(ctx, funcName, inputBytes)
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 context was cancelled, return that error instead of the plugin error
if ctx.Err() != nil {
skipMetrics = true
log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed)
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)
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))
// 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)
}
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
return result, fmt.Errorf("plugin call exited with code %d", exit)
}
if len(output) > 0 {
err = json.Unmarshal(output, &result)
if err != nil {
if err = json.Unmarshal(output, &result); err != nil {
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)
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, err == nil, elapsed.Milliseconds())
success = true
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

View File

@@ -24,10 +24,9 @@ type serviceContext struct {
manager *Manager
permissions *Permissions
config map[string]string
allowedUsers []string // User IDs this plugin can access
allUsers bool // If true, plugin can access all users
allowedLibraries []int // Library IDs this plugin can access
allLibraries bool // If true, plugin can access all libraries
userAccess UserAccess // User authorization for this plugin
allowedLibraries []int // Library IDs this plugin can access
allLibraries bool // If true, plugin can access all libraries
}
// hostServiceEntry defines a host service for table-driven registration.
@@ -52,7 +51,7 @@ var hostServices = []hostServiceEntry{
name: "SubsonicAPI",
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
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
},
},
@@ -115,7 +114,7 @@ var hostServices = []hostServiceEntry{
name: "Users",
hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil },
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
},
},
@@ -302,13 +301,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
var hostFunctions []extism.HostFunction
var closers []io.Closer
userAccess := NewUserAccess(p.AllUsers, allowedUsers)
svcCtx := &serviceContext{
pluginName: p.ID,
manager: m,
permissions: pkg.Manifest.Permissions,
config: pluginConfig,
allowedUsers: allowedUsers,
allUsers: p.AllUsers,
userAccess: userAccess,
allowedLibraries: allowedLibraries,
allLibraries: p.AllLibraries,
}
@@ -361,15 +361,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
m.mu.Lock()
m.plugins[p.ID] = &plugin{
name: p.ID,
path: p.Path,
manifest: pkg.Manifest,
compiled: compiled,
capabilities: capabilities,
closers: closers,
metrics: m.metrics,
allowedUserIDs: allowedUsers,
allUsers: p.AllUsers,
name: p.ID,
path: p.Path,
manifest: pkg.Manifest,
compiled: compiled,
capabilities: capabilities,
closers: closers,
metrics: m.metrics,
userAccess: userAccess,
}
m.mu.Unlock()

View File

@@ -12,15 +12,14 @@ import (
// plugin represents a loaded plugin
type plugin struct {
name string // Plugin name (from filename)
path string // Path to the wasm file
manifest *Manifest
compiled *extism.CompiledPlugin
capabilities []Capability // Auto-detected capabilities based on exported functions
closers []io.Closer // Cleanup functions to call on unload
metrics PluginMetricsRecorder
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
allUsers bool // If true, plugin can access all users
name string // Plugin name (from filename)
path string // Path to the wasm file
manifest *Manifest
compiled *extism.CompiledPlugin
capabilities []Capability // Auto-detected capabilities based on exported functions
closers []io.Closer // Cleanup functions to call on unload
metrics PluginMetricsRecorder
userAccess UserAccess // User authorization for this plugin
}
// instance creates a new plugin instance for the given context.

View File

@@ -110,6 +110,33 @@
},
"users": {
"$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"
}
}
}
},

View File

@@ -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
if m.Config != nil && m.Config.Schema != 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")
}
}
// 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
}

View File

@@ -4,6 +4,7 @@ package plugins
import "encoding/json"
import "fmt"
import "reflect"
// Artwork service permissions for generating artwork URLs
type ArtworkPermission struct {
@@ -45,6 +46,71 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error {
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
type Experimental struct {
// Threads corresponds to the JSON schema field "threads".
@@ -166,6 +232,9 @@ type Permissions struct {
// Cache corresponds to the JSON schema field "cache".
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 *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"`

View File

@@ -6,3 +6,10 @@ require (
github.com/extism/go-pdk v1.1.3
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
)

View 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
}

View 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) {}

View 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))
}
}
}
};
}

View File

@@ -5,6 +5,7 @@
//! This crate provides type definitions, traits, and registration macros
//! for implementing Navidrome plugin capabilities in Rust.
pub mod httpendpoint;
pub mod lifecycle;
pub mod metadata;
pub mod scheduler;

View File

@@ -33,11 +33,8 @@ func init() {
// ScrobblerPlugin is an adapter that wraps an Extism plugin and implements
// the scrobbler.Scrobbler interface for scrobbling to external services.
type ScrobblerPlugin struct {
name string
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
name string
plugin *plugin
}
// 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.
func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
// First check server-side authorization based on plugin configuration
if !s.isUserAllowed(userId) {
if !s.plugin.userAccess.IsAllowed(userId) {
return false
}
@@ -63,18 +60,6 @@ func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool
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
func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
username := getUsernameFromContext(ctx)

View File

@@ -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() {
It("successfully calls the plugin", func() {
track := &model.MediaFile{

View 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

View 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=

View 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() {}

View 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"
}
}
}

View 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

View 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=

View 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() {}

View 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"
}
}
}

View 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

View 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=

View 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() {}

View 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
View 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
}

View 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())
})
})
})

View File

@@ -333,76 +333,76 @@
},
"plugin": {
"name": "Plugin |||| Plugins",
"actions": {
"addConfig": "Tilføj konfiguration",
"disable": "Deaktivér",
"disabledDueToError": "Ret fejlen før aktivering",
"disabledLibrariesRequired": "Vælg biblioteker før aktivering",
"disabledUsersRequired": "Vælg brugere før aktivering",
"enable": "Aktivér",
"rescan": "Genskan"
},
"fields": {
"allLibraries": "Tillad alle biblioteker",
"allUsers": "Tillad alle brugere",
"id": "ID",
"name": "Navn",
"description": "Beskrivelse",
"version": "Version",
"author": "Forfatter",
"website": "Hjemmeside",
"permissions": "Tilladelser",
"enabled": "Aktiveret",
"status": "Status",
"path": "Sti",
"lastError": "Fejl",
"hasError": "Fejl",
"updatedAt": "Opdateret",
"createdAt": "Installeret",
"configKey": "Nøgle",
"configValue": "Værdi",
"createdAt": "Installeret",
"description": "Beskrivelse",
"enabled": "Aktiveret",
"hasError": "Fejl",
"id": "ID",
"lastError": "Fejl",
"name": "Navn",
"path": "Sti",
"permissions": "Tilladelser",
"selectedLibraries": "Valgte biblioteker",
"allUsers": "Tillad alle brugere",
"selectedUsers": "Valgte brugere",
"status": "Status",
"updatedAt": "Opdateret",
"version": "Version",
"website": "Hjemmeside"
"allLibraries": "Tillad alle biblioteker",
"selectedLibraries": "Valgte biblioteker"
},
"messages": {
"allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.",
"allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.",
"clickPermissions": "Klik på en tilladelse for detaljer",
"configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.",
"configValidationError": "Konfigurationsvalidering mislykkedes:",
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
"noConfig": "Ingen konfiguration angivet",
"noLibraries": "Ingen biblioteker valgt",
"noUsers": "Ingen brugere valgt",
"permissionReason": "Årsag",
"requiredHosts": "Påkrævede værter",
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.",
"usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'."
"sections": {
"status": "Status",
"info": "Pluginoplysninger",
"configuration": "Konfiguration",
"manifest": "Manifest",
"usersPermission": "Brugertilladelse",
"libraryPermission": "Bibliotekstilladelse"
},
"status": {
"enabled": "Aktiveret",
"disabled": "Deaktiveret"
},
"actions": {
"enable": "Aktivér",
"disable": "Deaktivér",
"disabledDueToError": "Ret fejlen før aktivering",
"disabledUsersRequired": "Vælg brugere før aktivering",
"disabledLibrariesRequired": "Vælg biblioteker før aktivering",
"addConfig": "Tilføj konfiguration",
"rescan": "Genskan"
},
"notifications": {
"disabled": "Plugin deaktiveret",
"enabled": "Plugin aktiveret",
"error": "Fejl ved opdatering af plugin",
"updated": "Plugin opdateret"
"disabled": "Plugin deaktiveret",
"updated": "Plugin opdateret",
"error": "Fejl ved opdatering af plugin"
},
"validation": {
"invalidJson": "Konfigurationen skal være gyldig JSON"
},
"messages": {
"configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.",
"clickPermissions": "Klik på en tilladelse for detaljer",
"noConfig": "Ingen konfiguration angivet",
"allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.",
"noUsers": "Ingen brugere valgt",
"permissionReason": "Årsag",
"usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'.",
"allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.",
"noLibraries": "Ingen biblioteker valgt",
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
"requiredHosts": "Påkrævede hosts",
"configValidationError": "Konfigurationsvalidering mislykkedes:",
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt."
},
"placeholders": {
"configKey": "nøgle",
"configValue": "værdi"
},
"sections": {
"configuration": "Konfiguration",
"info": "Pluginoplysninger",
"libraryPermission": "Bibliotekstilladelse",
"manifest": "Manifest",
"status": "Status",
"usersPermission": "Brugertilladelse"
},
"status": {
"disabled": "Deaktiveret",
"enabled": "Aktiveret"
},
"validation": {
"invalidJson": "Konfigurationen skal være gyldig JSON"
}
}
},
@@ -674,7 +674,8 @@
"exportSuccess": "Konfigurationen eksporteret til udklipsholder i TOML-format",
"exportFailed": "Kunne ikke kopiere konfigurationen",
"devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)",
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver"
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver",
"downloadToml": ""
}
},
"activity": {

View File

@@ -2,7 +2,7 @@
"languageName": "Euskara",
"resources": {
"song": {
"name": "Abestia |||| Abestiak",
"name": "Abestia |||| Abesti",
"fields": {
"albumArtist": "Albumaren artista",
"duration": "Iraupena",
@@ -10,6 +10,7 @@
"playCount": "Erreprodukzioak",
"title": "Titulua",
"artist": "Artista",
"composer": "Konpositorea",
"album": "Albuma",
"path": "Fitxategiaren bidea",
"libraryName": "Liburutegia",
@@ -33,9 +34,9 @@
"grouping": "Multzokatzea",
"mood": "Aldartea",
"participants": "Partaide gehiago",
"tags": "Traola gehiago",
"mappedTags": "Esleitutako traolak",
"rawTags": "Traola gordinak",
"tags": "Etiketa gehiago",
"mappedTags": "Esleitutako etiketak",
"rawTags": "Etiketa gordinak",
"missing": "Ez da aurkitu"
},
"actions": {
@@ -46,11 +47,12 @@
"shuffleAll": "Erreprodukzio aleatorioa",
"download": "Deskargatu",
"playNext": "Hurrengoa",
"info": "Erakutsi informazioa"
"info": "Erakutsi informazioa",
"instantMix": "Berehalako nahastea"
}
},
"album": {
"name": "Albuma |||| Albumak",
"name": "Albuma |||| Album",
"fields": {
"albumArtist": "Albumaren artista",
"artist": "Artista",
@@ -66,7 +68,7 @@
"date": "Recording Date",
"originalDate": "Jatorrizkoa",
"releaseDate": "Argitaratze-data",
"releases": "Argitaratzea |||| Argitaratzeak",
"releases": "Argitaratzea |||| Argitaratze",
"released": "Argitaratua",
"updatedAt": "Aktualizatze-data:",
"comment": "Iruzkina",
@@ -101,7 +103,7 @@
}
},
"artist": {
"name": "Artista |||| Artistak",
"name": "Artista |||| Artista",
"fields": {
"name": "Izena",
"albumCount": "Album kopurua",
@@ -330,6 +332,80 @@
"scanInProgress": "Araketa abian da…",
"noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
}
},
"plugin": {
"name": "Plugina |||| Plugin",
"fields": {
"id": "IDa",
"name": "Izena",
"description": "Deskribapena",
"version": "Bertsioa",
"author": "Autorea",
"website": "Webgunea",
"permissions": "Baimenak",
"enabled": "Gaituta",
"status": "Egoera",
"path": "Bidea",
"lastError": "Errorea",
"hasError": "Errorea",
"updatedAt": "Eguneratuta",
"createdAt": "Instalatuta",
"configKey": "Gakoa",
"configValue": "Balioa",
"allUsers": "Baimendu erabiltzaile guztiak",
"selectedUsers": "Hautatutako erabiltzaileak",
"allLibraries": "Baimendu liburutegi guztiak",
"selectedLibraries": "Hautatutako liburutegiak"
},
"sections": {
"status": "Egoera",
"info": "Pluginaren informazioa",
"configuration": "Konfigurazioa",
"manifest": "Manifestua",
"usersPermission": "Erabiltzaileen baimenak",
"libraryPermission": "Liburutegien baimenak"
},
"status": {
"enabled": "Gaituta",
"disabled": "Ezgaituta"
},
"actions": {
"enable": "Gaitu",
"disable": "Ezgaitu",
"disabledDueToError": "Konpondu errorea gaitu baino lehen",
"disabledUsersRequired": "Hautatu erabiltzaileak gaitu baino lehen",
"disabledLibrariesRequired": "Hautatu liburutegiak gaitu baino lehen",
"addConfig": "Gehitu konfigurazioa",
"rescan": "Arakatu berriro"
},
"notifications": {
"enabled": "Plugina gaituta",
"disabled": "Plugina ezgaituta",
"updated": "Plugina eguneratuta",
"error": "Errorea plugina eguneratzean"
},
"validation": {
"invalidJson": "Konfigurazioa baliozko JSON-a izan behar da"
},
"messages": {
"configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.",
"configValidationError": "Huts egin du konfigurazioaren balidazioak:",
"schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.",
"clickPermissions": "Sakatu baimen batean xehetasunetarako",
"noConfig": "Ez da konfiguraziorik ezarri",
"allUsersHelp": "Gaituta dagoenean, pluginak erabiltzaile guztiak atzitu ditzazke, baita etorkizunean sortuko direnak ere.",
"noUsers": "Ez da erabiltzailerik hautatu",
"permissionReason": "Arrazoia",
"usersRequired": "Plugin honek erabiltzaileen informaziora sarbidea behar du. Hautatu zein erabiltzaile atzitu dezakeen pluginak, edo gaitu 'Baimendu erabiltzaile guztiak'.",
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
"noLibraries": "Ez da liburutegirik hautatu",
"librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.",
"requiredHosts": "Beharrezko ostatatzaileak"
},
"placeholders": {
"configKey": "gakoa",
"configValue": "balioa"
}
}
},
"ra": {
@@ -483,6 +559,7 @@
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
"songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira",
"noSimilarSongsFound": "Ez da antzeko abestirik aurkitu",
"startingInstantMix": "Berehalako nahastea kargatzen…",
"noTopSongsFound": "Ez da aparteko abestirik aurkitu",
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",

View File

@@ -10,6 +10,7 @@
"playCount": "Lejátszások",
"title": "Cím",
"artist": "Előadó",
"composer": "Zeneszerző",
"album": "Album",
"path": "Elérési út",
"libraryName": "Könyvtár",
@@ -46,7 +47,8 @@
"shuffleAll": "Keverés",
"download": "Letöltés",
"playNext": "Lejátszás következőként",
"info": "Részletek"
"info": "Részletek",
"instantMix": "Instant keverés"
}
},
"album": {
@@ -325,6 +327,80 @@
"scanInProgress": "Szkennelés folyamatban...",
"noLibrariesAssigned": "Ehhez a felhasználóhoz nincsenek könyvtárak adva"
}
},
"plugin": {
"name": "Kiegészítő |||| Kiegészítők",
"fields": {
"id": "ID",
"name": "Név",
"description": "Leírás",
"version": "Verzió",
"author": "Fejlesztő",
"website": "Weboldal",
"permissions": "Engedélyek",
"enabled": "Engedélyezve",
"status": "Státusz",
"path": "Útvonal",
"lastError": "Hiba",
"hasError": "Hiba",
"updatedAt": "Frissítve",
"createdAt": "Telepítve",
"configKey": "Kulcs",
"configValue": "Érték",
"allUsers": "Összes felhasználó engedélyezése",
"selectedUsers": "Kiválasztott felhasználók engedélyezése",
"allLibraries": "Összes könyvtár engedélyezése",
"selectedLibraries": "Kiválasztott könyvtárak engedélyezése"
},
"sections": {
"status": "Státusz",
"info": "Kiegészítő információi",
"configuration": "Konfiguráció",
"manifest": "Manifest",
"usersPermission": "Felhasználói engedélyek",
"libraryPermission": "Könyvtári engedélyek"
},
"status": {
"enabled": "Engedélyezve",
"disabled": "Letiltva"
},
"actions": {
"enable": "Engedélyezés",
"disable": "Letiltás",
"disabledDueToError": "Javítsd ki a kiegészítő hibáját",
"disabledUsersRequired": "Válassz felhasználókat",
"disabledLibrariesRequired": "Válassz könyvtárakat",
"addConfig": "Konfiguráció hozzáadása",
"rescan": "Újraszkennelés"
},
"notifications": {
"enabled": "Kiegészítő engedélyezve",
"disabled": "Kiegészítő letiltva",
"updated": "Kiegészítő frissítve",
"error": "Hiba történt a kiegészítő frissítése közben"
},
"validation": {
"invalidJson": "A konfigurációs JSON érvénytelen"
},
"messages": {
"configHelp": "Konfiguráld a kiegészítőt kulcs-érték párokkal. Hagyd a mezőt üresen, ha nincs szükség konfigurációra.",
"configValidationError": "Helytelen konfiguráció:",
"schemaRenderError": "Nem sikerült megjeleníteni a konfigurációs űrlapot. A bővítmény sémája érvénytelen lehet.",
"clickPermissions": "Kattints egy engedélyre a részletekért",
"noConfig": "Nincs konfiguráció beállítva",
"allUsersHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott felhasználóhoz.",
"noUsers": "Nincsenek kiválasztott felhasználók",
"permissionReason": "Indok",
"usersRequired": "Ez a kiegészítő hozzáférést kér felhasználói információkhoz. Válaszd ki, melyik felhasználókat érheti el, vagy az 'Összes felhasználó engedélyezése' opciót.",
"allLibrariesHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott könyvtárhoz.",
"noLibraries": "Nincs kiválasztott könyvtár",
"librariesRequired": "Ez a kiegészítő hozzáférést kér könyvtárinformációkhoz. Válaszd ki, melyik könyvtárakat érheti el, vagy az 'Összes könyvtár engedélyezése' opciót.",
"requiredHosts": "Szükséges hostok"
},
"placeholders": {
"configKey": "kulcs",
"configValue": "érték"
}
}
},
"ra": {
@@ -402,7 +478,7 @@
"loading": "Betöltés",
"not_found": "Nem található",
"show": "%{name} #%{id}",
"empty": "Nincs %{name} még.",
"empty": "Nincsenek %{name}.",
"invite": "Szeretnél egyet hozzáadni?"
},
"input": {
@@ -478,6 +554,7 @@
"transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.",
"songsAddedToPlaylist": "1 szám hozzáadva a lejátszási listához |||| %{smart_count} szám hozzáadva a lejátszási listához",
"noSimilarSongsFound": "Nem találhatóak hasonló számok",
"startingInstantMix": "Instant keverés töltődik...",
"noTopSongsFound": "Nincsenek top számok",
"noPlaylistsAvailable": "Nem áll rendelkezésre",
"delete_user_title": "Felhasználó törlése '%{name}'",
@@ -591,6 +668,7 @@
"currentValue": "Jelenlegi érték",
"configurationFile": "Konfigurációs fájl",
"exportToml": "Konfiguráció exportálása (TOML)",
"downloadToml": "Konfiguráció letöltése (TOML)",
"exportSuccess": "Konfiguráció kiexportálva a vágólapra, TOML formában",
"exportFailed": "Nem sikerült kimásolni a konfigurációt",
"devFlagsHeader": "Fejlesztői beállítások (változások/eltávolítás jogát fenntartjuk)",

View File

@@ -674,7 +674,8 @@
"exportSuccess": "Configuração exportada para o clipboard em formato TOML",
"exportFailed": "Falha ao copiar configuração",
"devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)",
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras"
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras",
"downloadToml": "Baixar configuração (TOML)"
}
},
"activity": {

View File

File diff suppressed because it is too large Load Diff

113
server/e2e/doc.go Normal file
View File

@@ -0,0 +1,113 @@
// Package e2e provides end-to-end integration tests for the Navidrome Subsonic API.
//
// These tests exercise the full HTTP request/response cycle through the Subsonic API router,
// using a real SQLite database and real repository implementations while stubbing out external
// services (artwork, streaming, scrobbling, etc.) with noop implementations.
//
// # Test Infrastructure
//
// The suite uses [Ginkgo] v2 as the test runner and [Gomega] for assertions. It is invoked
// through the standard Go test entry point [TestSubsonicE2E], which initializes the test
// environment, creates a temporary SQLite database, and runs the specs.
//
// # Setup and Teardown
//
// During [BeforeSuite], the test infrastructure:
//
// 1. Creates a temporary SQLite database with WAL journal mode.
// 2. Initializes the schema via [db.Init].
// 3. Creates two test users: an admin ("admin") and a regular user ("regular"),
// both with the password "password".
// 4. Creates a single library ("Music Library") backed by a fake in-memory filesystem
// (scheme "fake:///music") using the [storagetest] package.
// 5. Populates the filesystem with a set of test tracks spanning multiple artists,
// albums, genres, and years.
// 6. Runs the scanner to import all metadata into the database.
// 7. Takes a snapshot of the database to serve as a golden baseline for test isolation.
//
// # Test Data
//
// The fake filesystem contains the following music library structure:
//
// Rock/The Beatles/Abbey Road/
// 01 - Come Together.mp3 (1969, Rock)
// 02 - Something.mp3 (1969, Rock)
// Rock/The Beatles/Help!/
// 01 - Help.mp3 (1965, Rock)
// Rock/Led Zeppelin/IV/
// 01 - Stairway To Heaven.mp3 (1971, Rock)
// Jazz/Miles Davis/Kind of Blue/
// 01 - So What.mp3 (1959, Jazz)
// Pop/
// 01 - Standalone Track.mp3 (2020, Pop)
//
// # Database Isolation
//
// Before each top-level Describe block, the [setupTestDB] function restores the database
// to its golden snapshot state using SQLite's ATTACH DATABASE mechanism. This copies all
// table data from the snapshot back into the main database, providing each test group with
// a clean, consistent starting state without the overhead of re-scanning the filesystem.
//
// A fresh [subsonic.Router] is also created for each test group, wired with real data store
// repositories and noop stubs for external services:
//
// - noopArtwork: returns [model.ErrNotFound] for all artwork requests.
// - noopStreamer: returns [model.ErrNotFound] for all stream requests.
// - noopArchiver: returns [model.ErrNotFound] for all archive requests.
// - noopProvider: returns empty results for all external metadata lookups.
// - noopPlayTracker: silently discards all scrobble events.
//
// # Request Helpers
//
// Tests build HTTP requests using the [buildReq] helper, which constructs a Subsonic API
// request with authentication parameters (username, password, API version "1.16.1", client
// name "test-client", and JSON format). Convenience wrappers include:
//
// - [doReq]: sends a request as the admin user and returns the parsed JSON response.
// - [doReqWithUser]: sends a request as a specific user.
// - [doRawReq] / [doRawReqWithUser]: returns the raw [httptest.ResponseRecorder] for
// binary content or status code inspection.
//
// Responses are parsed via [parseJSONResponse], which unwraps the Subsonic JSON envelope
// and returns the inner response map.
//
// # Test Organization
//
// Each test file covers a logical group of Subsonic API endpoints:
//
// - subsonic_system_test.go: ping, getLicense, getOpenSubsonicExtensions
// - subsonic_browsing_test.go: getMusicFolders, getIndexes, getArtists, getMusicDirectory,
// getArtist, getAlbum, getSong, getGenres
// - subsonic_searching_test.go: search2, search3
// - subsonic_album_lists_test.go: getAlbumList, getAlbumList2
// - subsonic_playlists_test.go: createPlaylist, getPlaylist, getPlaylists,
// updatePlaylist, deletePlaylist
// - subsonic_media_annotation_test.go: star, unstar, getStarred, setRating, scrobble
// - subsonic_media_retrieval_test.go: stream, download, getCoverArt, getAvatar,
// getLyrics, getLyricsBySongId
// - subsonic_bookmarks_test.go: createBookmark, getBookmarks, deleteBookmark,
// savePlayQueue, getPlayQueue
// - subsonic_radio_test.go: getInternetRadioStations, createInternetRadioStation,
// updateInternetRadioStation, deleteInternetRadioStation
// - subsonic_sharing_test.go: createShare, getShares, updateShare, deleteShare
// - subsonic_users_test.go: getUser, getUsers
// - subsonic_scan_test.go: getScanStatus, startScan
// - subsonic_multiuser_test.go: multi-user isolation and permission enforcement
// - subsonic_multilibrary_test.go: multi-library access control and data isolation
//
// Some test groups use Ginkgo's Ordered decorator to run tests sequentially within a block,
// allowing later tests to depend on state created by earlier ones (e.g., creating a playlist
// and then verifying it can be retrieved).
//
// # Running
//
// The e2e tests are included in the standard test suite and can be run with:
//
// make test PKG=./server/e2e # Run only e2e tests
// make test # Run all tests including e2e
// make test-race # Run with race detector
//
// [Ginkgo]: https://onsi.github.io/ginkgo/
// [Gomega]: https://onsi.github.io/gomega/
// [storagetest]: /core/storage/storagetest
package e2e

View File

@@ -25,24 +25,32 @@ import (
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/navidrome/navidrome/utils/gg"
"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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
if err := mergeFormIntoQuery(r); err != nil {
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)
})
}
@@ -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 {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var usr *model.User
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)
}
}
}
usr, err := authenticateRequest(ds, r)
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))
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 {
valid := false

View File

@@ -9,6 +9,7 @@ import TableBody from '@material-ui/core/TableBody'
import TableRow from '@material-ui/core/TableRow'
import TableCell from '@material-ui/core/TableCell'
import Paper from '@material-ui/core/Paper'
import CloudDownloadIcon from '@material-ui/icons/CloudDownload'
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import FileCopyIcon from '@material-ui/icons/FileCopy'
import Button from '@material-ui/core/Button'
@@ -245,6 +246,21 @@ const ConfigTabContent = ({ configData }) => {
}
}
const handleDownloadToml = () => {
const tomlContent = configToToml(configData, translate)
const tomlFile = new File([tomlContent], 'navidrome.toml', {
type: 'text/plain',
})
const tomlFileLink = document.createElement('a')
const tomlFileUrl = URL.createObjectURL(tomlFile)
tomlFileLink.href = tomlFileUrl
tomlFileLink.download = tomlFile.name
tomlFileLink.click()
URL.revokeObjectURL(tomlFileUrl)
}
return (
<div className={classes.configContainer}>
<Button
@@ -252,11 +268,23 @@ const ConfigTabContent = ({ configData }) => {
startIcon={<FileCopyIcon />}
onClick={handleCopyToml}
className={classes.copyButton}
disabled={!configData}
disabled={
!configData || !navigator.clipboard || !window.isSecureContext
}
size="small"
>
{translate('about.config.exportToml')}
</Button>
<Button
variant="outlined"
startIcon={<CloudDownloadIcon />}
onClick={handleDownloadToml}
className={classes.copyButton}
disabled={!configData}
size="small"
>
{translate('about.config.downloadToml')}
</Button>
<TableContainer className={classes.tableContainer}>
<Table size="small" stickyHeader>
<TableHead>

View File

@@ -673,6 +673,7 @@
"currentValue": "Current Value",
"configurationFile": "Configuration File",
"exportToml": "Export Configuration (TOML)",
"downloadToml": "Download Configuration (TOML)",
"exportSuccess": "Configuration exported to clipboard in TOML format",
"exportFailed": "Failed to copy configuration",
"devFlagsHeader": "Development Flags (subject to change/removal)",

View File

@@ -0,0 +1,181 @@
const stylesheet = `
/* Icon hover: pink */
.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
color: #ff79c6
}
/* Progress bar: purple */
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track {
background-color: #bd93f9
}
/* Volume bar: green */
.sound-operation .rc-slider-handle, .sound-operation .rc-slider-track {
background-color: #50fa7b !important
}
.sound-operation .rc-slider-handle:active {
box-shadow: 0 0 2px #50fa7b !important
}
/* Scrollbar: comment */
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
background-color: #6272a4;
}
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
box-shadow: 0 0 2px #bd93f9
}
/* Now playing icon: cyan */
.react-jinke-music-player-main .audio-item.playing svg {
color: #8be9fd
}
/* Now playing artist: cyan */
.react-jinke-music-player-main .audio-item.playing .player-singer {
color: #8be9fd !important
}
/* Loading spinner: orange */
.react-jinke-music-player-main .loading svg {
color: #ffb86c !important
}
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
border: hidden;
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
}
.rc-slider-rail, .rc-slider-track {
height: 6px;
}
.rc-slider {
padding: 3px 0;
}
.sound-operation > div:nth-child(4) {
transform: translateX(-50%) translateY(5%) !important;
}
.sound-operation {
padding: 4px 0;
}
/* Player panel background */
.react-jinke-music-player-main .music-player-panel {
background-color: #282a36;
color: #f8f8f2;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
}
/* Song title in player: foreground */
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-title {
color: #f8f8f2;
}
/* Duration/time text: yellow */
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .duration, .react-jinke-music-player-main .music-player-panel .panel-content .player-content .current-time {
color: #f1fa8c
}
/* Audio list panel */
.audio-lists-panel {
background-color: #282a36;
bottom: 6.25rem;
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
}
.audio-lists-panel-content .audio-item.playing {
background-color: transparent;
}
.audio-lists-panel-content .audio-item:nth-child(2n+1) {
background-color: transparent;
}
/* Playlist hover: current line */
.audio-lists-panel-content .audio-item:active,
.audio-lists-panel-content .audio-item:hover {
background-color: #44475a;
}
.audio-lists-panel-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: none;
}
/* Playlist header text: orange */
.audio-lists-panel-header-title {
color: #ffb86c;
}
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn {
background-color: transparent;
box-shadow: none;
}
.audio-lists-panel-content .audio-item {
line-height: 32px;
}
.react-jinke-music-player-main .music-player-panel .panel-content .img-content {
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
}
/* Lyrics: yellow */
.react-jinke-music-player-main .music-player-lyric {
color: #f1fa8c;
-webkit-text-stroke: 0.5px #282a36;
font-weight: bolder;
}
/* Lyric button active: yellow */
.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg {
color: #f1fa8c !important;
}
/* Playlist now playing: cyan */
.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
color: #8be9fd
}
/* Playlist hover icons: pink */
.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg {
color: #ff79c6
}
.audio-lists-panel-content .audio-item .player-icons {
scale: 75%;
}
/* Mobile */
.react-jinke-music-player-mobile-cover {
border: none;
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
}
.react-jinke-music-player .music-player-controller {
border: none;
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
color: #bd93f9;
}
.react-jinke-music-player .music-player-controller .music-player-controller-setting {
color: rgba(189, 147, 249, 0.3);
}
/* Mobile progress: green */
.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track {
background-color: #50fa7b;
}
.react-jinke-music-player-mobile-progress .rc-slider-handle {
border: none;
}
`
export default stylesheet

397
ui/src/themes/dracula.js Normal file
View File

@@ -0,0 +1,397 @@
import stylesheet from './dracula.css.js'
// Dracula color palette
const background = '#282a36'
const currentLine = '#44475a'
const foreground = '#f8f8f2'
const comment = '#6272a4'
const cyan = '#8be9fd'
const green = '#50fa7b'
const pink = '#ff79c6'
const purple = '#bd93f9'
const orange = '#ffb86c'
const red = '#ff5555'
const yellow = '#f1fa8c'
// Darker shade for surfaces
const surface = '#21222c'
// For Album, Playlist play button
const musicListActions = {
alignItems: 'center',
'@global': {
'button:first-child:not(:only-child)': {
'@media screen and (max-width: 720px)': {
transform: 'scale(1.5)',
margin: '1rem',
'&:hover': {
transform: 'scale(1.6) !important',
},
},
transform: 'scale(2)',
margin: '1.5rem',
minWidth: 0,
padding: 5,
transition: 'transform .3s ease',
backgroundColor: `${green} !important`,
color: background,
borderRadius: 500,
border: 0,
'&:hover': {
transform: 'scale(2.1)',
backgroundColor: `${green} !important`,
border: 0,
},
},
'button:only-child': {
margin: '1.5rem',
},
'button:first-child>span:first-child': {
padding: 0,
},
'button:first-child>span:first-child>span': {
display: 'none',
},
'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
{
color: foreground,
},
},
}
export default {
themeName: 'Dracula',
palette: {
primary: {
main: purple,
},
secondary: {
main: currentLine,
contrastText: foreground,
},
error: {
main: red,
},
type: 'dark',
background: {
default: background,
paper: surface,
},
},
overrides: {
MuiPaper: {
root: {
color: foreground,
backgroundColor: surface,
},
},
MuiAppBar: {
positionFixed: {
backgroundColor: `${currentLine} !important`,
boxShadow:
'rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px',
},
},
MuiDrawer: {
root: {
background: background,
},
},
MuiButton: {
textPrimary: {
color: purple,
},
textSecondary: {
color: foreground,
},
},
MuiIconButton: {
root: {
color: foreground,
},
},
MuiChip: {
root: {
backgroundColor: currentLine,
},
},
MuiFormGroup: {
root: {
color: foreground,
},
},
MuiFormLabel: {
root: {
color: comment,
'&$focused': {
color: purple,
},
},
},
MuiToolbar: {
root: {
backgroundColor: `${surface} !important`,
},
},
MuiOutlinedInput: {
root: {
'& $notchedOutline': {
borderColor: currentLine,
},
'&:hover $notchedOutline': {
borderColor: comment,
},
'&$focused $notchedOutline': {
borderColor: purple,
},
},
},
MuiFilledInput: {
root: {
backgroundColor: currentLine,
'&:hover': {
backgroundColor: comment,
},
'&$focused': {
backgroundColor: currentLine,
},
},
},
MuiTableRow: {
root: {
transition: 'background-color .3s ease',
'&:hover': {
backgroundColor: `${currentLine} !important`,
},
},
},
MuiTableHead: {
root: {
color: foreground,
background: surface,
},
},
MuiTableCell: {
root: {
color: foreground,
background: `${surface} !important`,
borderBottom: `1px solid ${currentLine}`,
},
head: {
color: `${yellow} !important`,
background: `${currentLine} !important`,
},
body: {
color: `${foreground} !important`,
},
},
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: green,
},
'&$checked + $track': {
backgroundColor: green,
},
},
},
NDAlbumGridView: {
albumName: {
marginTop: '0.5rem',
fontWeight: 700,
color: foreground,
},
albumSubtitle: {
color: comment,
},
albumContainer: {
backgroundColor: surface,
borderRadius: '8px',
padding: '.75rem',
transition: 'background-color .3s ease',
'&:hover': {
backgroundColor: currentLine,
},
},
albumPlayButton: {
backgroundColor: green,
borderRadius: '50%',
boxShadow: '0 8px 8px rgb(0 0 0 / 30%)',
padding: '0.35rem',
transition: 'padding .3s ease',
'&:hover': {
background: `${green} !important`,
padding: '0.45rem',
},
},
},
NDPlaylistDetails: {
container: {
background: `linear-gradient(${currentLine}, transparent)`,
borderRadius: 0,
paddingTop: '2.5rem !important',
boxShadow: 'none',
},
title: {
fontWeight: 700,
color: foreground,
},
details: {
fontSize: '.875rem',
color: comment,
},
},
NDAlbumDetails: {
root: {
background: `linear-gradient(${currentLine}, transparent)`,
borderRadius: 0,
boxShadow: 'none',
},
cardContents: {
alignItems: 'center',
paddingTop: '1.5rem',
},
recordName: {
fontWeight: 700,
color: foreground,
},
recordArtist: {
fontSize: '.875rem',
fontWeight: 700,
color: pink,
},
recordMeta: {
fontSize: '.875rem',
color: comment,
},
},
NDCollapsibleComment: {
commentBlock: {
fontSize: '.875rem',
color: comment,
},
},
NDAlbumShow: {
albumActions: musicListActions,
},
NDPlaylistShow: {
playlistActions: musicListActions,
},
NDAudioPlayer: {
audioTitle: {
color: foreground,
fontSize: '0.875rem',
},
songTitle: {
fontWeight: 400,
},
songInfo: {
fontSize: '0.675rem',
color: comment,
},
},
NDLogin: {
systemNameLink: {
color: purple,
},
welcome: {
color: foreground,
},
card: {
minWidth: 300,
background: background,
},
button: {
boxShadow: '3px 3px 5px #191a21',
},
},
NDMobileArtistDetails: {
bgContainer: {
background: `linear-gradient(to bottom, rgba(40 42 54 / 72%), ${surface})!important`,
},
},
RaLayout: {
content: {
padding: '0 !important',
background: surface,
},
root: {
backgroundColor: background,
},
},
RaList: {
content: {
backgroundColor: surface,
},
},
RaListToolbar: {
toolbar: {
backgroundColor: background,
padding: '0 .55rem !important',
},
},
RaSidebar: {
fixed: {
backgroundColor: background,
},
drawerPaper: {
backgroundColor: `${background} !important`,
},
},
MuiTableSortLabel: {
root: {
color: `${yellow} !important`,
'&:hover': {
color: `${orange} !important`,
},
'&$active': {
color: `${orange} !important`,
'&& $icon': {
color: `${orange} !important`,
},
},
},
},
RaMenuItemLink: {
root: {
color: foreground,
'&[aria-current="page"]': {
color: `${pink} !important`,
},
'&[aria-current="page"] .MuiListItemIcon-root': {
color: `${pink} !important`,
},
},
active: {
color: `${pink} !important`,
'& .MuiListItemIcon-root': {
color: `${pink} !important`,
},
},
},
RaLink: {
link: {
color: cyan,
},
},
RaButton: {
button: {
margin: '0 5px 0 5px',
},
},
RaPaginationActions: {
currentPageButton: {
border: `2px solid ${purple}`,
},
button: {
backgroundColor: currentLine,
minWidth: 48,
margin: '0 4px',
},
},
},
player: {
theme: 'dark',
stylesheet,
},
}

View File

@@ -9,6 +9,7 @@ import ElectricPurpleTheme from './electricPurple'
import NordTheme from './nord'
import GruvboxDarkTheme from './gruvboxDark'
import CatppuccinMacchiatoTheme from './catppuccinMacchiato'
import DraculaTheme from './dracula'
import NuclearTheme from './nuclear'
import AmusicTheme from './amusic'
import SquiddiesGlassTheme from './SquiddiesGlass'
@@ -22,6 +23,7 @@ export default {
// New themes should be added here, in alphabetic order
AmusicTheme,
CatppuccinMacchiatoTheme,
DraculaTheme,
ElectricPurpleTheme,
ExtraDarkTheme,
GreenTheme,