update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled

This commit is contained in:
2025-12-08 16:16:23 +01:00
commit c251f174ed
1349 changed files with 194301 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
package public
import (
"context"
"errors"
"net/http"
"net/url"
"path"
"strconv"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
)
func ImageURL(r *http.Request, artID model.ArtworkID, size int) string {
token := encodeArtworkID(artID)
uri := path.Join(consts.URLPathPublicImages, token)
params := url.Values{}
if size > 0 {
params.Add("size", strconv.Itoa(size))
}
return publicURL(r, uri, params)
}
func encodeArtworkID(artID model.ArtworkID) string {
token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()})
return token
}
func decodeArtworkID(tokenString string) (model.ArtworkID, error) {
token, err := auth.TokenAuth.Decode(tokenString)
if err != nil {
return model.ArtworkID{}, err
}
if token == nil {
return model.ArtworkID{}, errors.New("unauthorized")
}
err = jwt.Validate(token, jwt.WithRequiredClaim("id"))
if err != nil {
return model.ArtworkID{}, err
}
claims, err := token.AsMap(context.Background())
if err != nil {
return model.ArtworkID{}, err
}
id, ok := claims["id"].(string)
if !ok {
return model.ArtworkID{}, errors.New("invalid id type")
}
artID, err := model.ParseArtworkID(id)
if err == nil {
return artID, nil
}
// Try to default to mediafile artworkId (if used with a mediafileShare token)
return model.ParseArtworkID("mf-" + id)
}
func encodeMediafileShare(s model.Share, id string) string {
claims := map[string]any{"id": id}
if s.Format != "" {
claims["f"] = s.Format
}
if s.MaxBitRate != 0 {
claims["b"] = s.MaxBitRate
}
token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims)
return token
}

View File

@@ -0,0 +1,39 @@
package public
import (
"github.com/go-chi/jwtauth/v5"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("encodeArtworkID", func() {
Context("Public ID Encoding", func() {
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil)
})
It("returns a reversible string representation", func() {
id := model.NewArtworkID(model.KindArtistArtwork, "1234", nil)
encoded := encodeArtworkID(id)
decoded, err := decodeArtworkID(encoded)
Expect(err).ToNot(HaveOccurred())
Expect(decoded).To(Equal(id))
})
It("fails to decode an invalid token", func() {
_, err := decodeArtworkID("xx-123")
Expect(err).To(MatchError("invalid JWT"))
})
It("defaults to kind mediafile", func() {
encoded := encodeArtworkID(model.ArtworkID{})
id, err := decodeArtworkID(encoded)
Expect(err).ToNot(HaveOccurred())
Expect(id.Kind).To(Equal(model.KindMediaFileArtwork))
})
It("fails to decode a token without an id", func() {
token, _ := auth.CreatePublicToken(map[string]any{})
_, err := decodeArtworkID(token)
Expect(err).To(HaveOccurred())
})
})
})

View File

@@ -0,0 +1,18 @@
package public
import (
"net/http"
"github.com/navidrome/navidrome/utils/req"
)
func (pub *Router) handleDownloads(w http.ResponseWriter, r *http.Request) {
id, err := req.Params(r).String(":id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = pub.archiver.ZipShare(r.Context(), id, w)
checkShareError(r.Context(), w, err, id)
}

View File

@@ -0,0 +1,67 @@
package public
import (
"context"
"errors"
"io"
"net/http"
"time"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/req"
)
func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) {
// If context is already canceled, discard request without further processing
if r.Context().Err() != nil {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
p := req.Params(r)
id, _ := p.String(":id")
if id == "" {
log.Warn(r, "No id provided")
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
artId, err := decodeArtworkID(id)
if err != nil {
log.Error(r, "Error decoding artwork id", "id", id, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
size := p.IntOr("size", 0)
square := p.BoolOr("square", false)
imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square)
switch {
case errors.Is(err, context.Canceled):
return
case errors.Is(err, model.ErrNotFound):
log.Warn(r, "Couldn't find coverArt", "id", id, err)
http.Error(w, "Artwork not found", http.StatusNotFound)
return
case errors.Is(err, artwork.ErrUnavailable):
log.Debug(r, "Item does not have artwork", "id", id, err)
http.Error(w, "Artwork not found", http.StatusNotFound)
return
case err != nil:
log.Error(r, "Error retrieving coverArt", "id", id, err)
http.Error(w, "Error retrieving coverArt", http.StatusInternalServerError)
return
}
defer imgReader.Close()
w.Header().Set("Cache-Control", "public, max-age=315360000")
w.Header().Set("Last-Modified", lastUpdate.Format(time.RFC1123))
cnt, err := io.Copy(w, imgReader)
if err != nil {
log.Warn(ctx, "Error sending image", "count", cnt, err)
}
}

View File

@@ -0,0 +1,94 @@
package public
import (
"context"
"errors"
"net/http"
"path"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/ui"
"github.com/navidrome/navidrome/utils/req"
)
func (pub *Router) handleShares(w http.ResponseWriter, r *http.Request) {
id, err := req.Params(r).String(":id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If requested file is a UI asset, just serve it
_, err = ui.BuildAssets().Open(id)
if err == nil {
pub.assetsHandler.ServeHTTP(w, r)
return
}
// If it is not, consider it a share ID
s, err := pub.share.Load(r.Context(), id)
if err != nil {
checkShareError(r.Context(), w, err, id)
return
}
s = pub.mapShareInfo(r, *s)
server.IndexWithShare(pub.ds, ui.BuildAssets(), s)(w, r)
}
func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) {
id, err := req.Params(r).String(":id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If it is not, consider it a share ID
s, err := pub.share.Load(r.Context(), id)
if err != nil {
checkShareError(r.Context(), w, err, id)
return
}
s = pub.mapShareToM3U(r, *s)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "audio/x-mpegurl")
_, _ = w.Write([]byte(s.ToM3U8()))
}
func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) {
switch {
case errors.Is(err, model.ErrExpired):
log.Error(ctx, "Share expired", "id", id, err)
http.Error(w, "Share not available anymore", http.StatusGone)
case errors.Is(err, model.ErrNotFound):
log.Error(ctx, "Share not found", "id", id, err)
http.Error(w, "Share not found", http.StatusNotFound)
case errors.Is(err, model.ErrNotAuthorized):
log.Error(ctx, "Share is not downloadable", "id", id, err)
http.Error(w, "This share is not downloadable", http.StatusForbidden)
case err != nil:
log.Error(ctx, "Error retrieving share", "id", id, err)
http.Error(w, "Error retrieving share", http.StatusInternalServerError)
}
}
func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share {
s.URL = ShareURL(r, s.ID)
s.ImageURL = ImageURL(r, s.CoverArtID(), consts.UICoverArtSize)
for i := range s.Tracks {
s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID)
}
return &s
}
func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share {
for i := range s.Tracks {
id := encodeMediafileShare(s, s.Tracks[i].ID)
s.Tracks[i].Path = publicURL(r, path.Join(consts.URLPathPublic, "s", id), nil)
}
return &s
}

View File

@@ -0,0 +1,105 @@
package public
import (
"context"
"errors"
"io"
"net/http"
"strconv"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/req"
)
func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
tokenId, _ := p.String(":id")
info, err := decodeStreamInfo(tokenId)
if err != nil {
log.Error(ctx, "Error parsing shared stream info", err)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0)
if err != nil {
log.Error(ctx, "Error starting shared stream", err)
http.Error(w, "invalid request", http.StatusInternalServerError)
}
// Make sure the stream will be closed at the end, to avoid leakage
defer func() {
if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
log.Error("Error closing shared stream", "id", info.id, "file", stream.Name(), err)
}
}()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
if stream.Seekable() {
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
} else {
// If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length
w.Header().Set("Accept-Ranges", "none")
w.Header().Set("Content-Type", stream.ContentType())
estimateContentLength := p.BoolOr("estimateContentLength", false)
// if Client requests the estimated content-length, send it
if estimateContentLength {
length := strconv.Itoa(stream.EstimatedContentLength())
log.Trace(ctx, "Estimated content-length", "contentLength", length)
w.Header().Set("Content-Length", length)
}
if r.Method == http.MethodHead {
go func() { _, _ = io.Copy(io.Discard, stream) }()
} else {
c, err := io.Copy(w, stream)
if log.IsGreaterOrEqualTo(log.LevelDebug) {
if err != nil {
log.Error(ctx, "Error sending shared transcoded file", "id", info.id, err)
} else {
log.Trace(ctx, "Success sending shared transcode file", "id", info.id, "size", c)
}
}
}
}
}
type shareTrackInfo struct {
id string
format string
bitrate int
}
func decodeStreamInfo(tokenString string) (shareTrackInfo, error) {
token, err := auth.TokenAuth.Decode(tokenString)
if err != nil {
return shareTrackInfo{}, err
}
if token == nil {
return shareTrackInfo{}, errors.New("unauthorized")
}
err = jwt.Validate(token, jwt.WithRequiredClaim("id"))
if err != nil {
return shareTrackInfo{}, err
}
claims, err := token.AsMap(context.Background())
if err != nil {
return shareTrackInfo{}, err
}
id, ok := claims["id"].(string)
if !ok {
return shareTrackInfo{}, errors.New("invalid id type")
}
resp := shareTrackInfo{}
resp.id = id
resp.format, _ = claims["f"].(string)
resp.bitrate, _ = claims["b"].(int)
return resp, nil
}

85
server/public/public.go Normal file
View File

@@ -0,0 +1,85 @@
package public
import (
"net/http"
"net/url"
"path"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/ui"
)
type Router struct {
http.Handler
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
share core.Share
assetsHandler http.Handler
ds model.DataStore
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, share core.Share, archiver core.Archiver) *Router {
p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver}
shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic)
p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets())))
p.Handler = p.routes()
return p
}
func (pub *Router) routes() http.Handler {
r := chi.NewRouter()
r.Group(func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Group(func(r chi.Router) {
if conf.Server.DevArtworkMaxRequests > 0 {
log.Debug("Throttling public images endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests,
"backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout",
conf.Server.DevArtworkThrottleBacklogTimeout)
r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
conf.Server.DevArtworkThrottleBacklogTimeout))
}
r.HandleFunc("/img/{id}", pub.handleImages)
})
if conf.Server.EnableSharing {
r.HandleFunc("/s/{id}", pub.handleStream)
if conf.Server.EnableDownloads {
r.HandleFunc("/d/{id}", pub.handleDownloads)
}
r.HandleFunc("/{id}/m3u", pub.handleM3U)
r.HandleFunc("/{id}", pub.handleShares)
r.HandleFunc("/", pub.handleShares)
r.Handle("/*", pub.assetsHandler)
}
})
return r
}
func ShareURL(r *http.Request, id string) string {
uri := path.Join(consts.URLPathPublic, id)
return publicURL(r, uri, nil)
}
func publicURL(r *http.Request, u string, params url.Values) string {
if conf.Server.ShareURL != "" {
shareUrl, _ := url.Parse(conf.Server.ShareURL)
buildUrl, _ := url.Parse(u)
buildUrl.Scheme = shareUrl.Scheme
buildUrl.Host = shareUrl.Host
if len(params) > 0 {
buildUrl.RawQuery = params.Encode()
}
return buildUrl.String()
}
return server.AbsoluteURL(r, u, params)
}

View File

@@ -0,0 +1,17 @@
package public
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestPublicEndpoints(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Public Endpoints Suite")
}

View File

@@ -0,0 +1,56 @@
package public
import (
"net/http"
"net/url"
"path"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("publicURL", func() {
When("ShareURL is set", func() {
BeforeEach(func() {
conf.Server.ShareURL = "http://share.myotherserver.com"
})
It("uses the config value instead of AbsoluteURL", func() {
r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil)
uri := path.Join(consts.URLPathPublic, "123")
actual := publicURL(r, uri, nil)
Expect(actual).To(Equal("http://share.myotherserver.com/share/123"))
})
It("concatenates params if provided", func() {
r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil)
uri := path.Join(consts.URLPathPublicImages, "123")
params := url.Values{
"size": []string{"300"},
}
actual := publicURL(r, uri, params)
Expect(actual).To(Equal("http://share.myotherserver.com/share/img/123?size=300"))
})
})
When("ShareURL is not set", func() {
BeforeEach(func() {
conf.Server.ShareURL = ""
})
It("uses AbsoluteURL", func() {
r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil)
uri := path.Join(consts.URLPathPublic, "123")
actual := publicURL(r, uri, nil)
Expect(actual).To(Equal("https://myserver.com/share/123"))
})
It("concatenates params if provided", func() {
r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil)
uri := path.Join(consts.URLPathPublicImages, "123")
params := url.Values{
"size": []string{"300"},
}
actual := publicURL(r, uri, params)
Expect(actual).To(Equal("https://myserver.com/share/img/123?size=300"))
})
})
})