mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* refactor(model): extract canonical 128-bit base62 id codec * feat(model): generate random ids as canonical 128-bit base62 values * feat(scanner): emit legacy PIDs in canonical base62 encoding * feat(db): add id canonicalization transform for the uniform-ids migration * feat(db): migrate all ids to canonical 128-bit base62 encoding * fix(db): canonicalize ids in junction tables and JSON columns * chore(jellyfin): update id-family notes for uniform canonical ids * test(ids): harden codec input contract and migration edge coverage * refactor(model): use log.Fatal for Encode128 contract guard per project convention * fix(db): force full rescan after id migration for legacy PID configs * test(db): guard id-column inventory against schema drift * refactor(ids): compile-time Encode128 contract and unified column rewrite helper * refactor(db): apply review feedback to id migration Filter empty strings in collectColumn's SQL, reuse a prepared statement for rewriteColumn updates, and clarify the legacy ID functions' comment now that they emit the canonical encoding. * feat(auth): split session and public-link JWT secrets, rotating sessions on id migration * test(subsonic): initialize public token secret in helpers suite The suite sets auth.TokenAuth directly instead of calling auth.Init, so the new PublicTokenAuth was nil whenever Ginkgo's spec order ran a helpers spec before any spec that calls auth.Init, panicking in publicurl.ImageURL. * refactor(db): inline canonicalID into its only consumer, the uniform-ids migration * refactor(model): rename Encode128/Decode128 to Encode/Decode With every id now exactly 128 bits, the width suffix is redundant; the package-qualified id.Encode/id.Decode carries the same information. * test(db): make the id-columns guard classify JSON columns too The guard only inspected columns named id/pid/*_id, so it could not see ids embedded in JSON. Widen it to *_ids and to every JSON column, and drive the "covered" set from a new embeddedIDColumns list instead of the inline calls in the migration. Every JSON column the schema has now carries a verdict. The four denormalized caches -- media_file/album.participants, media_file/album.tags, album.folder_ids and artist.similar_artists -- hold only artist, tag and folder ids. Those all come from id.NewHash, whose 22-char base62 encoding of a 128-bit MD5 is already in canonical range, so canonicalID is the identity on them and the migration correctly leaves them alone. A new codec test pins that invariant, since the exemptions depend on it. Verified on a copy of a 727MB/96k-track production database: canonicalizing those four columns changed zero rows, and artist, tag and folder ids were themselves unchanged by the migration (only media_file ids moved, 95108 of 96666).
126 lines
4.1 KiB
Go
126 lines
4.1 KiB
Go
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.WriteHeader(http.StatusOK)
|
|
w.Header().Set("Content-Type", "audio/x-mpegurl")
|
|
_, _ = 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, s.ID)
|
|
s.ImageURL = publicurl.ImageURL(r, 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, 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
|
|
}
|