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).
127 lines
3.8 KiB
Go
127 lines
3.8 KiB
Go
package public
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
streampkg "github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
. "github.com/navidrome/navidrome/utils/gg"
|
|
"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
|
|
}
|
|
|
|
share, err := pub.ds.Share(ctx).Get(info.shareID)
|
|
if err != nil {
|
|
checkShareError(ctx, w, err, info.shareID)
|
|
return
|
|
}
|
|
if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
|
|
checkShareError(ctx, w, model.ErrExpired, info.shareID)
|
|
return
|
|
}
|
|
shareOwner, err := pub.ds.User(ctx).Get(share.UserID)
|
|
if err != nil {
|
|
log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
mf, err := pub.ds.MediaFile(ctx).Get(info.id)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
} else {
|
|
log.Error(ctx, "Error retrieving media file for shared stream", "id", info.id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
// 404 rather than 403 so the response doesn't reveal whether the id exists.
|
|
// The track must belong to the share AND be within the owner's libraries.
|
|
if !shareContainsTrack(share, mf.ID) || !shareOwner.HasLibraryAccess(mf.LibraryID) {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{
|
|
Format: info.format, BitRate: info.bitrate,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, streampkg.ErrTooManyTranscodes) {
|
|
w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds))
|
|
http.Error(w, "too many concurrent transcodes, please retry shortly", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
log.Error(ctx, "Error starting shared stream", err)
|
|
http.Error(w, "invalid request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 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))
|
|
|
|
n, err := stream.Serve(ctx, w, r)
|
|
if err != nil || n == 0 {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type shareTrackInfo struct {
|
|
id string
|
|
format string
|
|
bitrate int
|
|
shareID string
|
|
}
|
|
|
|
func shareContainsTrack(share *model.Share, mediaFileID string) bool {
|
|
return slices.ContainsFunc(share.Tracks, func(mf model.MediaFile) bool {
|
|
return mf.ID == mediaFileID
|
|
})
|
|
}
|
|
|
|
// decodeStreamInfo decodes the signed share-link token. This is a scoped
|
|
// public-share capability, not an auth credential; see encodeMediafileShare for
|
|
// why a JWT is used here.
|
|
func decodeStreamInfo(tokenString string) (shareTrackInfo, error) {
|
|
c, err := auth.ValidatePublic(tokenString)
|
|
if err != nil {
|
|
return shareTrackInfo{}, err
|
|
}
|
|
if c.ID == "" {
|
|
return shareTrackInfo{}, errors.New("required claim \"id\" not found")
|
|
}
|
|
if c.ShareID == "" {
|
|
return shareTrackInfo{}, errors.New("required claim \"sid\" not found")
|
|
}
|
|
return shareTrackInfo{
|
|
id: c.ID,
|
|
format: c.Format,
|
|
bitrate: c.BitRate,
|
|
shareID: c.ShareID,
|
|
}, nil
|
|
}
|