🏡 index : quine-global/quintodrome.git

package public

import (
	"context"
	"errors"
	"net/http"
	"path"

	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/core/auth"
	"github.com/navidrome/navidrome/core/publicurl"
	"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/gg"
	"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.Header().Set("Content-Type", "audio/x-mpegurl")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec
}

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.Context(), s.ID)
	s.ImageURL = publicurl.ImageURL(r.Context(), s.CoverArtID(), conf.Server.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.PublicURL(r.Context(), path.Join(consts.URLPathPublic, "s", id), nil)
	}
	return &s
}

// encodeMediafileShare builds the signed token embedded in a public share link
// for a single track.
//
// NOTE ON JWT USAGE: This is deliberately NOT part of Navidrome's authentication.
// The token is a signed, opaque capability that identifies one shared track
// (plus its transcode format/bitrate and the parent share id). We use a JWT here
// (reusing the library we already have) because it is a simple way to get three
// properties for a public link: the embedded ids can't be enumerated by guessing,
// the signature
// makes the claims tamper-evident, and the self-contained exp lets us reject
// stale links without a DB lookup. It carries no user identity (no subject, no
// admin flag) and grants access to nothing beyond the share it belongs to; the
// stream handler still verifies the share exists, is unexpired, and that the
// track is actually a member of it. An attacker who can forge these tokens
// necessarily already holds the public-link signing secret, a full-server
// compromise that is out of scope for the share boundary specifically.
func encodeMediafileShare(s model.Share, id string) string {
	claims := auth.Claims{
		ID:      id,
		Format:  s.Format,
		BitRate: s.MaxBitRate,
		ShareID: s.ID,
	}
	token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims)
	return token
}