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).
249 lines
8.2 KiB
Go
249 lines
8.2 KiB
Go
package public
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"time"
|
|
|
|
"github.com/go-chi/jwtauth/v5"
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
type mockStreamer struct {
|
|
req stream.Request
|
|
called bool
|
|
}
|
|
|
|
func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream.Request) (*stream.Stream, error) {
|
|
m.called = true
|
|
m.req = r
|
|
return nil, errors.New("mock: not implemented")
|
|
}
|
|
|
|
var _ = Describe("decodeStreamInfo", func() {
|
|
BeforeEach(func() {
|
|
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
})
|
|
|
|
It("decodes a valid token with all fields", func() {
|
|
claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
info, err := decodeStreamInfo(token)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(info.id).To(Equal("mf-123"))
|
|
Expect(info.format).To(Equal("mp3"))
|
|
Expect(info.bitrate).To(Equal(192))
|
|
Expect(info.shareID).To(Equal("share123"))
|
|
})
|
|
|
|
It("rejects an expired token", func() {
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims)
|
|
_, err := decodeStreamInfo(token)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("accepts a token without exp (non-expiring share)", func() {
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
info, err := decodeStreamInfo(token)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(info.id).To(Equal("mf-123"))
|
|
Expect(info.shareID).To(Equal("share123"))
|
|
})
|
|
|
|
It("rejects a token without an id claim", func() {
|
|
claims := auth.Claims{ShareID: "share123"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
_, err := decodeStreamInfo(token)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("rejects an invalid token string", func() {
|
|
_, err := decodeStreamInfo("not-a-valid-token")
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("rejects a token without a shareID claim", func() {
|
|
claims := auth.Claims{ID: "mf-123", Format: "opus"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
_, err := decodeStreamInfo(token)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("encodeMediafileShare", func() {
|
|
BeforeEach(func() {
|
|
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
})
|
|
|
|
It("includes the share ID in the token", func() {
|
|
exp := new(time.Now().Add(time.Hour))
|
|
s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp}
|
|
token := encodeMediafileShare(s, "mf-999")
|
|
info, err := decodeStreamInfo(token)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(info.shareID).To(Equal("shareABC"))
|
|
Expect(info.id).To(Equal("mf-999"))
|
|
Expect(info.format).To(Equal("mp3"))
|
|
Expect(info.bitrate).To(Equal(320))
|
|
})
|
|
|
|
It("creates a non-expiring token when share has no expiry", func() {
|
|
s := model.Share{ID: "shareXYZ", ExpiresAt: nil}
|
|
token := encodeMediafileShare(s, "mf-111")
|
|
info, err := decodeStreamInfo(token)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(info.shareID).To(Equal("shareXYZ"))
|
|
Expect(info.id).To(Equal("mf-111"))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("handleStream", func() {
|
|
var ds *tests.MockDataStore
|
|
var shareRepo *tests.MockShareRepo
|
|
var streamer *mockStreamer
|
|
var pub *Router
|
|
|
|
BeforeEach(func() {
|
|
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
ds = &tests.MockDataStore{}
|
|
shareRepo = &tests.MockShareRepo{}
|
|
ds.MockedShare = shareRepo
|
|
streamer = &mockStreamer{}
|
|
pub = &Router{ds: ds, streamer: streamer}
|
|
})
|
|
|
|
makeRequest := func(token string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest("GET", "/public/s/token?%3Aid="+token, nil)
|
|
w := httptest.NewRecorder()
|
|
pub.handleStream(w, r)
|
|
return w
|
|
}
|
|
|
|
shareOwnedBy := func(owner model.User, mf model.MediaFile) {
|
|
shareRepo.ID = "share123"
|
|
shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{mf}}
|
|
userRepo := tests.CreateMockUserRepo()
|
|
Expect(userRepo.Put(&owner)).To(Succeed())
|
|
ds.MockedUser = userRepo
|
|
mfRepo := tests.CreateMockMediaFileRepo()
|
|
mfRepo.SetData(model.MediaFiles{mf})
|
|
ds.MockedMediaFile = mfRepo
|
|
}
|
|
|
|
It("passes all validation and reaches the streamer for a valid token", func() {
|
|
shareOwnedBy(
|
|
model.User{ID: "owner1", UserName: "owner1", IsAdmin: true},
|
|
model.MediaFile{ID: "mf-123", Title: "Test Song"},
|
|
)
|
|
|
|
claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
makeRequest(token)
|
|
|
|
Expect(streamer.called).To(BeTrue())
|
|
Expect(streamer.req.Format).To(Equal("mp3"))
|
|
Expect(streamer.req.BitRate).To(Equal(192))
|
|
})
|
|
|
|
It("returns 404 when the track is outside the share owner's libraries", func() {
|
|
shareOwnedBy(
|
|
model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}},
|
|
model.MediaFile{ID: "mf-restricted", Title: "Other Lib Track", LibraryID: 2},
|
|
)
|
|
|
|
claims := auth.Claims{ID: "mf-restricted", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
w := makeRequest(token)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(streamer.called).To(BeFalse())
|
|
})
|
|
|
|
It("returns 404 when the track is not a member of the share", func() {
|
|
owner := model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}
|
|
userRepo := tests.CreateMockUserRepo()
|
|
Expect(userRepo.Put(&owner)).To(Succeed())
|
|
ds.MockedUser = userRepo
|
|
mfRepo := tests.CreateMockMediaFileRepo()
|
|
mfRepo.SetData(model.MediaFiles{{ID: "mf-shared"}, {ID: "mf-other"}})
|
|
ds.MockedMediaFile = mfRepo
|
|
shareRepo.ID = "share123"
|
|
shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{{ID: "mf-shared"}}}
|
|
|
|
claims := auth.Claims{ID: "mf-other", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
w := makeRequest(token)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(streamer.called).To(BeFalse())
|
|
})
|
|
|
|
It("streams a track inside the share owner's libraries", func() {
|
|
shareOwnedBy(
|
|
model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}},
|
|
model.MediaFile{ID: "mf-ok", Title: "OK", LibraryID: 1},
|
|
)
|
|
|
|
claims := auth.Claims{ID: "mf-ok", Format: "mp3", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
makeRequest(token)
|
|
|
|
Expect(streamer.called).To(BeTrue())
|
|
})
|
|
|
|
It("returns 400 for an expired token", func() {
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 404 when share has been deleted", func() {
|
|
shareRepo.ID = "other-share"
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "deleted-share"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 410 when share has been set to expired", func() {
|
|
shareRepo.ID = "share123"
|
|
shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: new(time.Now().Add(-time.Hour))}
|
|
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusGone))
|
|
})
|
|
|
|
It("returns 500 when share lookup fails", func() {
|
|
shareRepo.Error = errors.New("db error")
|
|
claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
|
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
|
|
It("returns 400 for tokens without a shareID", func() {
|
|
claims := auth.Claims{ID: "mf-123"}
|
|
token, _ := auth.CreatePublicToken(claims)
|
|
w := makeRequest(token)
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
Expect(streamer.called).To(BeFalse())
|
|
})
|
|
|
|
It("returns 400 for an invalid token", func() {
|
|
w := makeRequest("not-a-valid-token")
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
})
|