Compare commits

..

12 Commits

Author SHA1 Message Date
Deluan
a91d7e9453 chore(deps): update go-taglib to v0.0.0-20260220032326 for MKA fixes
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-19 22:32:06 -05:00
Deluan
e033189b6f chore(go-taglib): update version to 2.2 WASM and add debug logging
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-19 21:21:57 -05:00
Deluan
6025746070 chore(make): rename run-docker target to docker-run for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-19 20:24:15 -05:00
Deluan
d946ecd981 chore(deps): update cross-taglib version to 2.2.0-1
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-19 20:23:58 -05:00
Deluan
8d9143e80e chore(deps): update go-taglib fork with MKA/Matroska support
Bump deluan/go-taglib to cf75207bfff8, which upgrades the underlying
taglib to v2.2 and adds Matroska container format detection and
metadata handling (MKA audio files).
2026-02-19 18:56:42 -05:00
Deluan Quintão
08a71320ea fix(ui): make toggle switches visible in Gruvbox Dark theme (#5063) (#5064)
The secondary color (#3c3836) matches the panel/table cell background,
making checked MuiSwitch thumbs invisible. Add MuiSwitch override using
Gruvbox cyan (#458588), consistent with existing interactive elements.
2026-02-18 15:38:20 -05:00
Raphael Catolino
44a5482493 fix(ui): activity Indicator switching constantly between online/offline (#5054)
When using HTTP2, setting the writeTimeout too low causes the channel to
close before the keepAlive event has a chance of beeing sent.

Signed-off-by: rca <raphael.catolino@gmail.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-02-17 14:47:20 -05:00
Deluan
5fa8356b31 chore(deps): bump golangci-lint to v2.10.0 and suppress new gosec false positives
Bump golangci-lint from v2.9.0 to v2.10.0, which includes a newer gosec
with additional taint-analysis rules (G117, G703, G704, G705) and a
stricter G101 check. Added inline //nolint:gosec comments to suppress
21 false positives across 19 files: struct fields flagged as secrets
(G117), w.Write calls flagged as XSS (G705), HTTP client calls flagged
as SSRF (G704), os.Stat/os.ReadFile/os.Remove flagged as path traversal
(G703), and a sort mapping flagged as hardcoded credentials (G101).

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 09:28:42 -05:00
Deluan Quintão
cad9cdc53e fix(scanner): preserve created_at when moving songs between libraries (#5055)
* fix: preserve created_at when moving songs between libraries (#5050)

When songs are moved between libraries, their creation date was being
reset to the current time, causing them to incorrectly appear in
"Recently Added". Three changes fix this:

1. Add hash:"ignore" to AlbumID in MediaFile struct so that Equals()
   works for cross-library moves (AlbumID includes library prefix,
   making hashes always differ between libraries)

2. Preserve album created_at in moveMatched() via CopyAttributes,
   matching the pattern already used in persistAlbum() for
   within-library album ID changes

3. Only set CreatedAt in Put() when it's zero (new files), and
   explicitly copy missing.CreatedAt to the target in moveMatched()
   as defense-in-depth for the INSERT code path

* test: add regression tests for created_at preservation (#5050)

Add tests covering the three aspects of the fix:
- Scanner: moveMatched preserves missing track's created_at
- Scanner: CopyAttributes called for album created_at on album change
- Scanner: CopyAttributes not called when album ID stays the same
- Persistence: Put sets CreatedAt to now for new files with zero value
- Persistence: Put preserves non-zero CreatedAt on insert
- Persistence: Put does not reset CreatedAt on update

Also adds CopyAttributes to MockAlbumRepo for test support.

* test: verify album created_at is updated in cross-library move test (#5050)

Added end-to-end assertion in the cross-library move test to verify that
the new album's CreatedAt field is actually set to the original value after
CopyAttributes runs, not just that the method was called. This strengthens
the test by confirming the mock correctly propagates the timestamp.
2026-02-17 08:37:05 -05:00
Deluan
b774133cd1 chore(deps): update go-sqlite3 to v1.14.34 and pocketbase/dbx to v1.12.0
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 08:35:02 -05:00
Alanna
a20d56c137 fix(ui): prevent "Play Next" restarting play at top of queue (#5049)
Set playIndex when rebuilding the queue in reducePlayNext so the music
player library knows which track is currently playing. Without this, the
library's loadNewAudioLists defaults playIndex to 0, causing playback to
restart from the top of the queue on rapid "Play Next" actions.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:34:24 -05:00
Deluan
b64d8ad334 fix(server): return 404 instead of 500 for non-existent playlists
The native API endpoints GET /playlist/{id}/tracks and
GET /playlist/{id}/tracks/{id} were panicking with a nil pointer
dereference (resulting in a 500) when the playlist did not exist.
This happened because Tracks() returns nil for missing playlists,
and the nil repository was passed directly to the rest handler.
Extracted a shared playlistTracksHandler that checks for nil and
returns 404 early. Added tests covering both the error and happy paths.
2026-02-15 22:39:27 -05:00
87 changed files with 721 additions and 2583 deletions

View File

@@ -14,7 +14,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends ffmpeg
# Install TagLib from cross-taglib releases
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
ARG CROSS_TAGLIB_VERSION="2.2.0-1"
ARG TARGETARCH
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \

View File

@@ -8,7 +8,7 @@
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24",
"CROSS_TAGLIB_VERSION": "2.1.1-1"
"CROSS_TAGLIB_VERSION": "2.2.0-1"
}
},
"workspaceMount": "",

View File

@@ -14,7 +14,7 @@ concurrency:
cancel-in-progress: true
env:
CROSS_TAGLIB_VERSION: "2.1.1-2"
CROSS_TAGLIB_VERSION: "2.2.0-1"
CGO_CFLAGS_ALLOW: "--define-prefix"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}

View File

@@ -28,7 +28,7 @@ COPY --from=xx-build /out/ /usr/bin/
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.1.1-2
ARG CROSS_TAGLIB_VERSION=2.2.0-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
# wget in busybox can't follow redirects

View File

@@ -19,8 +19,8 @@ PLATFORMS ?= $(SUPPORTED_PLATFORMS)
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.9.0
CROSS_TAGLIB_VERSION ?= 2.2.0-1
GOLANGCI_LINT_VERSION ?= v2.10.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@@ -201,8 +201,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
@du -h binaries/msi/*.msi
.PHONY: docker-msi
run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=<tag>
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<tag>"; exit 1; fi
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
if [ -f navidrome.toml ]; then \
@@ -213,7 +213,7 @@ run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker
fi; \
fi; \
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
.PHONY: run-docker
.PHONY: docker-run
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi

View File

@@ -65,7 +65,7 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
}
type authResponse struct {
JWT string `json:"jwt"`
JWT string `json:"jwt"` //nolint:gosec
}
var result authResponse

View File

@@ -20,6 +20,7 @@ import (
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
@@ -43,7 +44,7 @@ func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
}
func (e extractor) Version() string {
return "go-taglib (TagLib 2.1.1 WASM)"
return "2.2 WASM"
}
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
@@ -279,4 +280,7 @@ func init() {
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
return &extractor{fsys}
})
conf.AddHook(func() {
log.Debug("go-taglib version", "version", extractor{}.Version())
})
}

View File

@@ -110,7 +110,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
if err != nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx)))
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) //nolint:gosec
return
}

View File

@@ -57,7 +57,7 @@ type listenBrainzResponse struct {
}
type listenBrainzRequest struct {
ApiKey string
ApiKey string //nolint:gosec
Body listenBrainzRequestBody
}

View File

@@ -14,13 +14,10 @@ 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"
@@ -141,13 +138,6 @@ 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

@@ -172,8 +172,8 @@ type TagConf struct {
type lastfmOptions struct {
Enabled bool
ApiKey string
Secret string
ApiKey string //nolint:gosec
Secret string //nolint:gosec
Language string
ScrobbleFirstArtistOnly bool
@@ -183,7 +183,7 @@ type lastfmOptions struct {
type spotifyOptions struct {
ID string
Secret string
Secret string //nolint:gosec
}
type deezerOptions struct {
@@ -208,7 +208,7 @@ type httpHeaderOptions struct {
type prometheusOptions struct {
Enabled bool
MetricsPath string
Password string
Password string //nolint:gosec
}
type AudioDeviceDefinition []string
@@ -239,13 +239,11 @@ type inspectOptions struct {
}
type pluginsOptions struct {
Enabled bool
Folder string
CacheSize string
AutoReload bool
LogLevel string
EndpointRequestLimit int
EndpointRequestWindow time.Duration
Enabled bool
Folder string
CacheSize string
AutoReload bool
LogLevel string
}
type extAuthOptions struct {
@@ -673,8 +671,6 @@ 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)
@@ -752,7 +748,7 @@ func getConfigFile(cfgFile string) string {
}
cfgFile = os.Getenv("ND_CONFIGFILE")
if cfgFile != "" {
if _, err := os.Stat(cfgFile); err == nil {
if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec
return cfgFile
}
}

View File

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

View File

@@ -230,7 +230,7 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, err
hc := http.Client{Timeout: 5 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
req.Header.Set("User-Agent", consts.HTTPUserAgent)
resp, err := hc.Do(req)
resp, err := hc.Do(req) //nolint:gosec
if err != nil {
return nil, "", err
}

View File

@@ -108,7 +108,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := hc.Do(req)
resp, err := hc.Do(req) //nolint:gosec
if err != nil {
log.Trace(ctx, "Could not send Insights data", err)
return

View File

@@ -44,7 +44,7 @@ func newLocalStorage(u url.URL) storage.Storage {
func (s *localStorage) FS() (storage.MusicFS, error) {
path := s.u.Path
if _, err := os.Stat(path); err != nil {
if _, err := os.Stat(path); err != nil { //nolint:gosec
return nil, fmt.Errorf("%w: %s", err, path)
}
return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil

6
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-20260212150743-3f1b97cb0d1e
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260220032326-c5973f82d98a
)
require (
@@ -46,13 +46,13 @@ require (
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/maruel/natural v1.3.0
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-sqlite3 v1.14.33
github.com/mattn/go-sqlite3 v1.14.34
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.26.0
github.com/prometheus/client_golang v1.23.2
github.com/rjeczalik/notify v0.9.3

12
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-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/go-taglib v0.0.0-20260220032326-c5973f82d98a h1:2RzbQ2iFX+A5eDAswk00p6wXzsw1OiCcyHE5Pbj6VIU=
github.com/deluan/go-taglib v0.0.0-20260220032326-c5973f82d98a/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=
@@ -179,8 +179,8 @@ github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@@ -210,8 +210,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=

View File

@@ -38,7 +38,7 @@ type MediaFile struct {
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead
// AlbumArtist is the display name used for the album artist.
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
AlbumID string `structs:"album_id" json:"albumId"`
AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"`
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
TrackNumber int `structs:"track_number" json:"trackNumber"`
DiscNumber int `structs:"disc_number" json:"discNumber"`

View File

@@ -22,7 +22,7 @@ type User struct {
Password string `structs:"-" json:"-"`
// This is used to set or change a password when calling Put. If it is empty, the password is not changed.
// It is received from the UI with the name "password"
NewPassword string `structs:"password,omitempty" json:"password,omitempty"`
NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec
// If changing the password, this is also required
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
}

View File

@@ -138,7 +138,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
"missing": booleanFilter,
"library_id": artistLibraryIdFilter,
})
r.setSortMappings(map[string]string{
r.setSortMappings(map[string]string{ //nolint:gosec
"name": "order_artist_name",
"starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",

View File

@@ -148,7 +148,9 @@ func (r *mediaFileRepository) Exists(id string) (bool, error) {
}
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
m.CreatedAt = time.Now()
if m.CreatedAt.IsZero() {
m.CreatedAt = time.Now()
}
id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m})
if err != nil {
return err

View File

@@ -104,6 +104,68 @@ var _ = Describe("MediaRepository", func() {
}
})
Describe("Put CreatedAt behavior (#5050)", func() {
It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() {
before := time.Now().Add(-time.Second)
newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "/test/created-at-zero.mp3"}
Expect(mr.Put(&newFile)).To(Succeed())
retrieved, err := mr.Get(newFile.ID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.CreatedAt).To(BeTemporally(">", before))
_ = mr.Delete(newFile.ID)
})
It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() {
originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC)
newFile := model.MediaFile{
ID: id.NewRandom(),
LibraryID: 1,
Path: "/test/created-at-preserved.mp3",
CreatedAt: originalTime,
}
Expect(mr.Put(&newFile)).To(Succeed())
retrieved, err := mr.Get(newFile.ID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
_ = mr.Delete(newFile.ID)
})
It("does not reset CreatedAt when updating an existing file", func() {
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
fileID := id.NewRandom()
newFile := model.MediaFile{
ID: fileID,
LibraryID: 1,
Path: "/test/created-at-update.mp3",
Title: "Original Title",
CreatedAt: originalTime,
}
Expect(mr.Put(&newFile)).To(Succeed())
// Update the file with a new title but zero CreatedAt
updatedFile := model.MediaFile{
ID: fileID,
LibraryID: 1,
Path: "/test/created-at-update.mp3",
Title: "Updated Title",
// CreatedAt is zero - should NOT overwrite the stored value
}
Expect(mr.Put(&updatedFile)).To(Succeed())
retrieved, err := mr.Get(fileID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.Title).To(Equal("Updated Title"))
// CreatedAt should still be the original time (not reset)
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
_ = mr.Delete(fileID)
})
})
It("checks existence of mediafiles in the DB", func() {
Expect(mr.Exists(songAntenna.ID)).To(BeTrue())
Expect(mr.Exists("666")).To(BeFalse())

View File

@@ -1,52 +0,0 @@
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

@@ -1,81 +0,0 @@
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

@@ -1,14 +0,0 @@
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,27 +364,6 @@ 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 ""
}
}
@@ -487,7 +466,6 @@ 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")
@@ -582,15 +560,9 @@ func rustConstName(name string) string {
// skipSerializingFunc returns the appropriate skip_serializing_if function name.
func skipSerializingFunc(goType string) string {
if goType == "[]byte" {
return "Vec::is_empty"
}
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") {
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") {
return "Option::is_none"
}
if strings.HasPrefix(goType, "map[") {
return "HashMap::is_empty"
}
switch goType {
case "string":
return "String::is_empty"

View File

@@ -1432,20 +1432,12 @@ type OnInitOutput struct {
var _ = Describe("Rust Generation", func() {
Describe("skipSerializingFunc", 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() {
It("should return Option::is_none for pointer, slice, and map 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"))
})
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"))
Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none"))
})
It("should return String::is_empty for string type", func() {

View File

@@ -269,7 +269,6 @@ 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,68 +635,6 @@ 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,10 +9,6 @@ package {{.Package}}
import (
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
{{- if .Capability.HasRawMethods}}
"encoding/binary"
"encoding/json"
{{- end}}
)
{{- /* Generate type alias definitions */ -}}
@@ -60,7 +56,6 @@ func (e {{$typeName}}) Error() string { return string(e) }
{{- end}}
{{- /* Generate struct definitions */ -}}
{{- $capability := .Capability}}
{{- range .Capability.Structs}}
{{- if .Doc}}
@@ -73,12 +68,8 @@ 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}}
@@ -181,53 +172,6 @@ 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}}
@@ -272,7 +216,6 @@ func {{exportFunc .}}() int32 {
pdk.SetError(err)
return -1
}
{{- end}}
{{- end}}
return 0

View File

@@ -52,7 +52,6 @@ pub const {{rustConstName $v.Name}}: &'static str = {{$v.Value}};
{{- end}}
{{- /* Generate struct definitions */ -}}
{{- $capability := .Capability}}
{{- range .Capability.Structs}}
{{- if .Doc}}
@@ -67,16 +66,12 @@ pub struct {{.Name}} {
{{- if .Doc}}
{{rustDocComment .Doc | indent 4}}
{{- end}}
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
#[serde(skip)]
pub {{rustFieldName .Name}}: {{fieldRustType .}},
{{- else if .OmitEmpty}}
{{- if .OmitEmpty}}
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
pub {{rustFieldName .Name}}: {{fieldRustType .}},
{{- else}}
#[serde(default)]
pub {{rustFieldName .Name}}: {{fieldRustType .}},
{{- end}}
pub {{rustFieldName .Name}}: {{fieldRustType .}},
{{- end}}
}
{{- end}}
@@ -129,56 +124,6 @@ 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}}
@@ -201,7 +146,6 @@ macro_rules! register_{{snakeCase .Package}} {
{{- end}}
}
{{- end}}
{{- end}}
};
}
{{- else}}
@@ -227,56 +171,6 @@ 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}}
@@ -298,7 +192,6 @@ macro_rules! {{registerMacroName .Name}} {
Ok(())
{{- end}}
}
{{- end}}
};
}
{{- end}}

View File

@@ -53,7 +53,6 @@ func (e {{$typeName}}) Error() string { return string(e) }
{{- end}}
{{- /* Generate struct definitions */ -}}
{{- $capability := .Capability}}
{{- range .Capability.Structs}}
{{- if .Doc}}
@@ -66,12 +65,8 @@ 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,16 +48,6 @@ 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)
@@ -74,7 +64,6 @@ 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,14 +54,6 @@ 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.
@@ -214,12 +206,7 @@ func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema
for _, field := range st.Fields {
propName := getJSONFieldName(field)
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))
}
addToMap(&schema.Properties, propName, buildProperty(field, knownTypes))
if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty {
schema.Required = append(schema.Required, propName)
@@ -259,12 +246,6 @@ 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:]
@@ -283,55 +264,6 @@ 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,139 +719,4 @@ 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,19 +26,27 @@ 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 {
pluginName string
router SubsonicRouter
ds model.DataStore
userAccess UserAccess
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{}
}
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
func newSubsonicAPIService(pluginName string, router SubsonicRouter, ds model.DataStore, userAccess UserAccess) host.SubsonicAPIService {
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{}{}
}
return &subsonicAPIServiceImpl{
pluginName: pluginName,
router: router,
ds: ds,
userAccess: userAccess,
pluginID: pluginID,
router: router,
ds: ds,
allowedUserIDs: allowedUserIDs,
allUsers: allUsers,
userIDMap: userIDMap,
}
}
@@ -66,12 +74,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.pluginName, "user", username, err)
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err)
return nil, err
}
// Add required Subsonic API parameters
query.Set("c", s.pluginName) // Client name (plugin ID)
query.Set("c", s.pluginID) // Client name (plugin ID)
query.Set("v", subsonicAPIVersion) // API version
if setJSON {
query.Set("f", "json") // Response format
@@ -86,8 +94,11 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string,
RawQuery: query.Encode(),
}
// Use http.NewRequest (not WithContext) to avoid inheriting Chi RouteContext;
// auth context is set explicitly below via request.WithInternalAuth.
// 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.
httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
@@ -124,13 +135,14 @@ func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (strin
}
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
if s.userAccess.allUsers {
// If allUsers is true, allow any user
if s.allUsers {
return nil
}
// Must have at least one allowed user configured
if !s.userAccess.HasConfiguredUsers() {
return fmt.Errorf("no users configured for plugin %s", s.pluginName)
// 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)
}
// Look up the user by username to get their ID
@@ -143,7 +155,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username
}
// Check if the user's ID is in the allowed list
if !s.userAccess.IsAllowed(usr.ID) {
if _, ok := s.userIDMap[usr.ID]; !ok {
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, NewUserAccess(false, []string{"user2"}))
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
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, NewUserAccess(false, []string{"user2"}))
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
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, NewUserAccess(false, []string{"user1"}))
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
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, NewUserAccess(false, []string{"admin1"}))
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false)
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, NewUserAccess(true, nil))
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
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, NewUserAccess(true, nil))
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
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, NewUserAccess(false, nil))
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false)
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, NewUserAccess(false, []string{}))
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false)
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, NewUserAccess(true, nil))
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
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, NewUserAccess(true, nil))
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
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, NewUserAccess(false, []string{"user1"}))
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
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, NewUserAccess(true, nil))
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
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, NewUserAccess(true, nil))
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
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, NewUserAccess(false, []string{"user2"}))
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
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, NewUserAccess(true, nil))
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
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, NewUserAccess(true, nil))
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
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, NewUserAccess(true, nil))
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
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, NewUserAccess(true, nil))
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=testuser")

View File

@@ -9,14 +9,16 @@ import (
)
type usersServiceImpl struct {
ds model.DataStore
userAccess UserAccess
ds model.DataStore
allowedUsers []string // User IDs this plugin can access
allUsers bool // If true, plugin can access all users
}
func newUsersService(ds model.DataStore, userAccess UserAccess) host.UsersService {
func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) host.UsersService {
return &usersServiceImpl{
ds: ds,
userAccess: userAccess,
ds: ds,
allowedUsers: allowedUsers,
allUsers: allUsers,
}
}
@@ -26,9 +28,17 @@ 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 s.userAccess.IsAllowed(u.ID) {
// If allUsers is true, include all users
// Otherwise, only include users in the allowed list
if s.allUsers || allowedMap[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, NewUserAccess(true, nil))
service = newUsersService(ds, nil, true)
})
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, NewUserAccess(false, []string{"user1", "user3"}))
service = newUsersService(ds, []string{"user1", "user3"}, false)
})
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, NewUserAccess(false, []string{}))
service = newUsersService(ds, []string{}, false)
})
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, NewUserAccess(true, nil))
service = newUsersService(ds, nil, true)
})
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, NewUserAccess(true, nil))
service = newUsersService(ds, nil, true)
})
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, NewUserAccess(false, []string{"user1", "user2"}))
service = newUsersService(ds, []string{"user1", "user2"}, false)
})
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, NewUserAccess(false, []string{"user2", "user3"}))
service = newUsersService(ds, []string{"user2", "user3"}, false)
})
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, NewUserAccess(true, nil))
service = newUsersService(ds, nil, true)
})
It("should propagate the error", func() {

View File

@@ -1,189 +0,0 @@
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

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

View File

@@ -2,7 +2,6 @@ package plugins
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
@@ -61,147 +60,39 @@ 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 {
if err = json.Unmarshal(output, &result); err != nil {
err = json.Unmarshal(output, &result)
if err != nil {
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
return result, err
}
}
success = true
// Record metrics for successful calls (or JSON unmarshal failures)
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, err == nil, elapsed.Milliseconds())
log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
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
return result, err
}
// extismLogger is a helper to log messages from Extism plugins

View File

@@ -24,9 +24,10 @@ type serviceContext struct {
manager *Manager
permissions *Permissions
config map[string]string
userAccess UserAccess // User authorization for this plugin
allowedLibraries []int // Library IDs this plugin can access
allLibraries bool // If true, plugin can access all libraries
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
}
// hostServiceEntry defines a host service for table-driven registration.
@@ -51,7 +52,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.userAccess)
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
return host.RegisterSubsonicAPIHostFunctions(service), nil
},
},
@@ -114,7 +115,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.userAccess)
service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
return host.RegisterUsersHostFunctions(service), nil
},
},
@@ -301,14 +302,13 @@ 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,
userAccess: userAccess,
allowedUsers: allowedUsers,
allUsers: p.AllUsers,
allowedLibraries: allowedLibraries,
allLibraries: p.AllLibraries,
}
@@ -361,14 +361,15 @@ 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,
userAccess: userAccess,
name: p.ID,
path: p.Path,
manifest: pkg.Manifest,
compiled: compiled,
capabilities: capabilities,
closers: closers,
metrics: m.metrics,
allowedUserIDs: allowedUsers,
allUsers: p.AllUsers,
}
m.mu.Unlock()

View File

@@ -12,14 +12,15 @@ 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
userAccess UserAccess // User authorization for this plugin
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
}
// instance creates a new plugin instance for the given context.

View File

@@ -110,33 +110,6 @@
},
"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,15 +32,6 @@ 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 {
@@ -73,14 +64,6 @@ 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,7 +4,6 @@ package plugins
import "encoding/json"
import "fmt"
import "reflect"
// Artwork service permissions for generating artwork URLs
type ArtworkPermission struct {
@@ -46,71 +45,6 @@ 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".
@@ -232,9 +166,6 @@ 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,10 +6,3 @@ 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

@@ -1,126 +0,0 @@
// 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

@@ -1,65 +0,0 @@
// 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

@@ -1,156 +0,0 @@
// 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,7 +5,6 @@
//! 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,8 +33,11 @@ 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
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
}
// IsAuthorized checks if the user is authorized with this scrobbler.
@@ -42,7 +45,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.plugin.userAccess.IsAllowed(userId) {
if !s.isUserAllowed(userId) {
return false
}
@@ -60,6 +63,18 @@ 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,6 +71,41 @@ 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

@@ -1,16 +0,0 @@
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

@@ -1,14 +0,0 @@
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

@@ -1,61 +0,0 @@
// 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

@@ -1,16 +0,0 @@
{
"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

@@ -1,16 +0,0 @@
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

@@ -1,14 +0,0 @@
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

@@ -1,45 +0,0 @@
// 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

@@ -1,13 +0,0 @@
{
"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

@@ -1,16 +0,0 @@
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

@@ -1,14 +0,0 @@
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

@@ -1,77 +0,0 @@
// 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

@@ -1,16 +0,0 @@
{
"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"
}
}
}

View File

@@ -1,35 +0,0 @@
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

@@ -1,64 +0,0 @@
//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

@@ -158,7 +158,7 @@ func writeTargetsToFile(targets []model.ScanTarget) (string, error) {
for _, target := range targets {
if _, err := fmt.Fprintln(tmpFile, target.String()); err != nil {
os.Remove(tmpFile.Name())
os.Remove(tmpFile.Name()) //nolint:gosec
return "", fmt.Errorf("failed to write to temp file: %w", err)
}
}

View File

@@ -2,6 +2,7 @@ package scanner
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
@@ -267,6 +268,10 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
oldAlbumID := missing.AlbumID
newAlbumID := target.AlbumID
// Preserve the original created_at from the missing file, so moved tracks
// don't appear in "Recently Added"
target.CreatedAt = missing.CreatedAt
// Update the target media file with the missing file's ID. This effectively "moves" the track
// to the new location while keeping its annotations and references intact.
target.ID = missing.ID
@@ -298,6 +303,14 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
}
// Keep created_at field from previous instance of the album, so moved albums
// don't appear in "Recently Added"
if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil {
if !errors.Is(err, model.ErrNotFound) {
log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err)
}
}
// Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
p.processedAlbumAnnotations[newAlbumID] = true
}

View File

@@ -724,6 +724,120 @@ var _ = Describe("phaseMissingTracks", func() {
}) // End of Context "with multiple libraries"
})
Describe("CreatedAt preservation (#5050)", func() {
var albumRepo *tests.MockAlbumRepo
BeforeEach(func() {
albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
albumRepo.ReassignAnnotationCalls = make(map[string]string)
albumRepo.CopyAttributesCalls = make(map[string]string)
})
It("should preserve the missing track's created_at when moving within a library", func() {
originalTime := time.Date(2020, 3, 15, 10, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
ID: "1", PID: "A", Path: "old/song.mp3",
AlbumID: "album-1",
LibraryID: 1,
CreatedAt: originalTime,
Tags: model.Tags{"title": []string{"My Song"}},
Size: 100,
}
matchedTrack := model.MediaFile{
ID: "2", PID: "A", Path: "new/song.mp3",
AlbumID: "album-1", // Same album
LibraryID: 1,
CreatedAt: time.Now(), // Much newer
Tags: model.Tags{"title": []string{"My Song"}},
Size: 100,
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matchedTrack},
}
_, err := phase.processMissingTracks(in)
Expect(err).ToNot(HaveOccurred())
movedTrack, _ := ds.MediaFile(ctx).Get("1")
Expect(movedTrack.Path).To(Equal("new/song.mp3"))
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
})
It("should preserve created_at during cross-library moves with album change", func() {
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
ID: "missing-ca", PID: "B", Path: "lib1/song.mp3",
AlbumID: "old-album",
LibraryID: 1,
CreatedAt: originalTime,
}
matchedTrack := model.MediaFile{
ID: "matched-ca", PID: "B", Path: "lib2/song.mp3",
AlbumID: "new-album",
LibraryID: 2,
CreatedAt: time.Now(),
}
// Set up albums so CopyAttributes can find them
albumRepo.SetData(model.Albums{
{ID: "old-album", LibraryID: 1, CreatedAt: originalTime},
{ID: "new-album", LibraryID: 2, CreatedAt: time.Now()},
})
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Track's created_at should be preserved from the missing file
movedTrack, _ := ds.MediaFile(ctx).Get("missing-ca")
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
// Album's created_at should be copied from old to new
Expect(albumRepo.CopyAttributesCalls).To(HaveKeyWithValue("old-album", "new-album"))
// Verify the new album's CreatedAt was actually updated
newAlbum, err := albumRepo.Get("new-album")
Expect(err).ToNot(HaveOccurred())
Expect(newAlbum.CreatedAt).To(Equal(originalTime))
})
It("should not copy album created_at when album ID does not change", func() {
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
ID: "missing-same", PID: "C", Path: "dir1/song.mp3",
AlbumID: "same-album",
LibraryID: 1,
CreatedAt: originalTime,
}
matchedTrack := model.MediaFile{
ID: "matched-same", PID: "C", Path: "dir2/song.mp3",
AlbumID: "same-album", // Same album
LibraryID: 1,
CreatedAt: time.Now(),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Track's created_at should still be preserved
movedTrack, _ := ds.MediaFile(ctx).Get("missing-same")
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
// CopyAttributes should NOT have been called (same album)
Expect(albumRepo.CopyAttributesCalls).To(BeEmpty())
})
})
Describe("Album Annotation Reassignment", func() {
var (
albumRepo *tests.MockAlbumRepo

View File

@@ -80,7 +80,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e
}
c := http.Client{Timeout: imageRequestTimeout}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil)
resp, err := c.Do(req) //nolint:bodyclose // No need to close resp.Body, it will be closed via the CachedStream wrapper
resp, err := c.Do(req) //nolint:bodyclose,gosec // No need to close resp.Body, it will be closed via the CachedStream wrapper
if errors.Is(err, context.DeadlineExceeded) {
defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline)
return strings.NewReader(string(defaultImage)), nil

View File

@@ -24,8 +24,9 @@ type Broker interface {
const (
keepAliveFrequency = 15 * time.Second
writeTimeOut = 5 * time.Second
bufferSize = 1
// The timeout must be higher than the keepAliveFrequency, or the lack of activity will cause the channel to close.
writeTimeOut = keepAliveFrequency + 5*time.Second
bufferSize = 1
)
type (
@@ -104,7 +105,7 @@ func writeEvent(ctx context.Context, w io.Writer, event message, timeout time.Du
log.Debug(ctx, "Error setting write timeout", err)
}
_, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data)
_, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data) //nolint:gosec
if err != nil {
return err
}

View File

@@ -60,7 +60,7 @@ func inspect(ds model.DataStore) http.HandlerFunc {
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(response); err != nil {
if _, err := w.Write(response); err != nil { //nolint:gosec
log.Error(ctx, "Error sending response to client", err)
}
}

View File

@@ -207,7 +207,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
_, err = w.Write(resp)
_, err = w.Write(resp) //nolint:gosec
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -243,7 +243,7 @@ func (api *Router) addInsightsRoute(r chi.Router) {
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
last, success := api.insights.LastRun(r.Context())
if conf.Server.EnableInsightsCollector {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) //nolint:gosec
} else {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
}

View File

@@ -19,47 +19,33 @@ import (
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
func getPlaylist(ds model.DataStore) http.HandlerFunc {
// Add a middleware to capture the playlistId
wrapper := func(handler restHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
constructor := func(ctx context.Context) rest.Repository {
plsRepo := ds.Playlist(ctx)
plsId := chi.URLParam(r, "playlistId")
p := req.Params(r)
start := p.Int64Or("_start", 0)
return plsRepo.Tracks(plsId, start == 0)
}
handler(constructor).ServeHTTP(w, r)
}
}
func playlistTracksHandler(ds model.DataStore, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("accept")
if strings.ToLower(accept) == "audio/x-mpegurl" {
plsId := chi.URLParam(r, "playlistId")
tracks := ds.Playlist(r.Context()).Tracks(plsId, refreshSmartPlaylist(r))
if tracks == nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
handler(func(ctx context.Context) rest.Repository { return tracks }).ServeHTTP(w, r)
}
}
func getPlaylist(ds model.DataStore) http.HandlerFunc {
handler := playlistTracksHandler(ds, rest.GetAll, func(r *http.Request) bool {
return req.Params(r).Int64Or("_start", 0) == 0
})
return func(w http.ResponseWriter, r *http.Request) {
if strings.ToLower(r.Header.Get("accept")) == "audio/x-mpegurl" {
handleExportPlaylist(ds)(w, r)
return
}
wrapper(rest.GetAll)(w, r)
handler(w, r)
}
}
func getPlaylistTrack(ds model.DataStore) http.HandlerFunc {
// Add a middleware to capture the playlistId
wrapper := func(handler restHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
constructor := func(ctx context.Context) rest.Repository {
plsRepo := ds.Playlist(ctx)
plsId := chi.URLParam(r, "playlistId")
return plsRepo.Tracks(plsId, true)
}
handler(constructor).ServeHTTP(w, r)
}
}
return wrapper(rest.Get)
return playlistTracksHandler(ds, rest.Get, func(*http.Request) bool { return true })
}
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
@@ -73,7 +59,7 @@ func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
return
}
w.WriteHeader(http.StatusCreated)
_, err = w.Write([]byte(pls.ToM3U8()))
_, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec
if err != nil {
log.Error(ctx, "Error sending m3u contents", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -104,7 +90,7 @@ func handleExportPlaylist(ds model.DataStore) http.HandlerFunc {
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name)
w.Header().Set("Content-Disposition", disposition)
_, err = w.Write([]byte(pls.ToM3U8()))
_, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec
if err != nil {
log.Error(ctx, "Error sending playlist", "name", pls.Name)
return
@@ -176,7 +162,7 @@ func addToPlaylist(ds model.DataStore) http.HandlerFunc {
count += c
// Must return an object with an ID, to satisfy ReactAdmin `create` call
_, err = fmt.Fprintf(w, `{"added":%d}`, count)
_, err = fmt.Fprintf(w, `{"added":%d}`, count) //nolint:gosec
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -218,7 +204,7 @@ func reorderItem(ds model.DataStore) http.HandlerFunc {
return
}
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id))
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id)) //nolint:gosec
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -239,6 +225,6 @@ func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(data)
_, _ = w.Write(data) //nolint:gosec
}
}

View File

@@ -0,0 +1,167 @@
package nativeapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type mockPlaylistTrackRepo struct {
model.PlaylistTrackRepository
tracks model.PlaylistTracks
}
func (m *mockPlaylistTrackRepo) Count(...rest.QueryOptions) (int64, error) {
return int64(len(m.tracks)), nil
}
func (m *mockPlaylistTrackRepo) ReadAll(...rest.QueryOptions) (any, error) {
return m.tracks, nil
}
func (m *mockPlaylistTrackRepo) EntityName() string {
return "playlist_track"
}
func (m *mockPlaylistTrackRepo) NewInstance() any {
return &model.PlaylistTrack{}
}
func (m *mockPlaylistTrackRepo) Read(id string) (any, error) {
for _, t := range m.tracks {
if t.ID == id {
return &t, nil
}
}
return nil, rest.ErrNotFound
}
var _ = Describe("Playlist Tracks Endpoint", func() {
var (
router http.Handler
ds *tests.MockDataStore
plsRepo *tests.MockPlaylistRepo
userRepo *tests.MockedUserRepo
w *httptest.ResponseRecorder
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Minute
plsRepo = &tests.MockPlaylistRepo{}
userRepo = tests.CreateMockUserRepo()
ds = &tests.MockDataStore{
MockedPlaylist: plsRepo,
MockedUser: userRepo,
MockedProperty: &tests.MockedPropertyRepo{},
}
auth.Init(ds)
testUser := model.User{
ID: "user-1",
UserName: "testuser",
Name: "Test User",
IsAdmin: false,
NewPassword: "testpass",
}
err := userRepo.Put(&testUser)
Expect(err).ToNot(HaveOccurred())
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})
createAuthenticatedRequest := func(method, path string) *http.Request {
req := httptest.NewRequest(method, path, nil)
testUser := model.User{ID: "user-1", UserName: "testuser"}
token, err := auth.CreateToken(&testUser)
Expect(err).ToNot(HaveOccurred())
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
return req
}
Describe("GET /playlist/{playlistId}/tracks", func() {
It("returns 404 when playlist does not exist", func() {
req := createAuthenticatedRequest("GET", "/playlist/non-existent/tracks")
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns tracks when playlist exists", func() {
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"},
{ID: "2", MediaFileID: "mf-2", PlaylistID: "pls-1"},
},
}
req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks")
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.PlaylistTrack
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response).To(HaveLen(2))
Expect(response[0].ID).To(Equal("1"))
Expect(response[1].ID).To(Equal("2"))
})
})
Describe("GET /playlist/{playlistId}/tracks/{id}", func() {
It("returns 404 when playlist does not exist", func() {
req := createAuthenticatedRequest("GET", "/playlist/non-existent/tracks/1")
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns the track when playlist exists", func() {
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"},
},
}
req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks/1")
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response model.PlaylistTrack
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.ID).To(Equal("1"))
Expect(response.MediaFileID).To(Equal("mf-1"))
})
It("returns 404 when track does not exist in playlist", func() {
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
tracks: model.PlaylistTracks{},
}
req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks/999")
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
})

View File

@@ -87,7 +87,7 @@ func getQueue(ds model.DataStore) http.HandlerFunc {
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(resp)
_, _ = w.Write(resp) //nolint:gosec
}
}

View File

@@ -59,7 +59,7 @@ func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) {
s = pub.mapShareToM3U(r, *s)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "audio/x-mpegurl")
_, _ = w.Write([]byte(s.ToM3U8()))
_, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec
}
func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) {

View File

@@ -244,7 +244,7 @@ func (s *Server) frontendAssetsHandler() http.Handler {
// It provides detailed error messages for common issues like encrypted private keys.
func validateTLSCertificates(certFile, keyFile string) error {
// Read the key file to check for encryption
keyData, err := os.ReadFile(keyFile)
keyData, err := os.ReadFile(keyFile) //nolint:gosec
if err != nil {
return fmt.Errorf("reading TLS key file: %w", err)
}

View File

@@ -363,7 +363,7 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
}
}
if _, err := w.Write(response); err != nil {
if _, err := w.Write(response); err != nil { //nolint:gosec
log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
}
}

View File

@@ -25,32 +25,24 @@ 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) {
if err := mergeFormIntoQuery(r); err != nil {
err := r.ParseForm()
if 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)
})
}
@@ -103,64 +95,54 @@ 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()
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)
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
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)
}
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "API: Invalid login", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
} else if err != nil {
log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
}
} else {
p := req.Params(r)
username, _ := p.String("u")
pass, _ := p.String("p")
token, _ := p.String("t")
salt, _ := p.String("s")
jwt, _ := p.String("jwt")
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
return
}
switch {
case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
case err != nil:
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
default:
err = validateCredentials(usr, pass, token, salt, jwt)
if err != nil {
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
}
}
}
if err != nil {
sendError(w, r, newError(responses.ErrorAuthenticationFail))
return
}
@@ -171,19 +153,6 @@ 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

@@ -21,6 +21,7 @@ type MockAlbumRepo struct {
Err bool
Options model.QueryOptions
ReassignAnnotationCalls map[string]string // prevID -> newID
CopyAttributesCalls map[string]string // fromID -> toID
}
func (m *MockAlbumRepo) SetError(err bool) {
@@ -142,6 +143,32 @@ func (m *MockAlbumRepo) ReassignAnnotation(prevID string, newID string) error {
return nil
}
// CopyAttributes copies attributes from one album to another
func (m *MockAlbumRepo) CopyAttributes(fromID, toID string, columns ...string) error {
if m.Err {
return errors.New("unexpected error")
}
from, ok := m.Data[fromID]
if !ok {
return model.ErrNotFound
}
to, ok := m.Data[toID]
if !ok {
return model.ErrNotFound
}
for _, col := range columns {
switch col {
case "created_at":
to.CreatedAt = from.CreatedAt
}
}
if m.CopyAttributesCalls == nil {
m.CopyAttributesCalls = make(map[string]string)
}
m.CopyAttributesCalls[fromID] = toID
return nil
}
// SetRating sets the rating for an album
func (m *MockAlbumRepo) SetRating(rating int, itemID string) error {
if m.Err {

View File

@@ -8,8 +8,9 @@ import (
type MockPlaylistRepo struct {
model.PlaylistRepository
Entity *model.Playlist
Error error
Entity *model.Playlist
Error error
TracksReturn model.PlaylistTrackRepository
}
func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) {
@@ -22,6 +23,10 @@ func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) {
return m.Entity, nil
}
func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository {
return m.TracksReturn
}
func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) {
if m.Error != nil {
return 0, m.Error

View File

@@ -127,10 +127,12 @@ const reducePlayNext = (state, { data }) => {
const newQueue = []
const current = state.current || {}
let foundPos = false
let currentIndex = 0
state.queue.forEach((item) => {
newQueue.push(item)
if (item.uuid === current.uuid) {
foundPos = true
currentIndex = newQueue.length - 1
Object.keys(data).forEach((id) => {
newQueue.push(mapToAudioLists(data[id]))
})
@@ -145,6 +147,7 @@ const reducePlayNext = (state, { data }) => {
return {
...state,
queue: newQueue,
playIndex: foundPos ? currentIndex : undefined,
clear: true,
}
}

View File

@@ -97,6 +97,16 @@ export default {
boxShadow: '3px 3px 5px #3c3836',
},
},
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: '#458588',
},
'&$checked + $track': {
backgroundColor: '#458588',
},
},
},
NDMobileArtistDetails: {
bgContainer: {
background: