mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-12 20:37:12 +02:00
* feat(jellyfin): add Quick Connect sign-in Jellyfin clients can now sign in without a password: the client shows a 6-digit code, a signed-in user approves it, and the client redeems a secret for its access token. - core/quickconnect: in-memory store shared by both routers through wire. Codes expire after 10 minutes; a secret redeems only once (Jellyfin allows repeats for 10 minutes); at most 1000 pending requests. - Jellyfin API: Initiate, Connect, Authorize and AuthenticateWithQuickConnect. Admins may approve for another user via UserId, like Swiftfin's admin page. Initiate and redeem share the login rate limiter; Connect does not, since Finamp and Streamyfin poll it every second. - Web UI: a Quick Connect item in the user menu looks up the code and shows the app and device before approving, so a user can't be tricked into approving an unknown device blindly. - Jellyfin.QuickConnect option, on by default like Jellyfin. It only matters when the Jellyfin API is enabled. * refactor(jellyfin): tidy Quick Connect naming and route guards Group the Quick Connect routes under one requireQuickConnect guard, make the request's device a named field so req.Device.ID can't be mistaken for a request id, and rename the web API response type to quickConnectDevice. * refactor(jellyfin): remove duplicated Jellyfin date formatting function * test(jellyfin): set play count and starred in the song fixture literal * refactor(jellyfin): inline the Quick Connect redeem body and use the shared date helper * fix(jellyfin): bound the client fields Quick Connect keeps in memory Initiate is unauthenticated and keeps the Client, Device, DeviceId and Version header fields for up to ten minutes. With no header size limit, each pending request could hold about 1 MB, and even a short field kept the whole header alive because the parsed values are substrings of it. Reject fields over 512 bytes and copy the stored values. Also answer 500 instead of 401 when the redeem user lookup fails for a reason other than the user being gone. * fix(jellyfin): rate-limit Quick Connect code approval Any signed-in user could try codes without limit on the Jellyfin Authorize endpoint and the web UI lookup/authorize endpoints, and so could approve another person's pending device for their own account. Apply the same per-IP limiter as the login (AuthRequestLimit/AuthWindowLength) to both surfaces.
214 lines
7.8 KiB
Go
214 lines
7.8 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("System", func() {
|
|
var api *Router
|
|
BeforeEach(func() { api = &Router{} })
|
|
|
|
It("returns public system info without auth", func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Jellyfin.ServerName = ""
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/System/Info/Public", nil)
|
|
api.getPublicSystemInfo(w, r)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json"))
|
|
var info dto.PublicSystemInfo
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
|
|
Expect(info.Id).ToNot(BeEmpty())
|
|
Expect(info.Version).To(Equal(jellyfinVersion))
|
|
Expect(info.ProductName).To(Equal("Jellyfin Server"))
|
|
Expect(info.ServerName).To(HavePrefix("Navidrome"))
|
|
})
|
|
|
|
It("returns authenticated system info with the public fields plus library monitor support", func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Jellyfin.ServerName = ""
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/System/Info", nil)
|
|
api.getSystemInfo(w, r)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json"))
|
|
var info dto.SystemInfo
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
|
|
Expect(info.Id).ToNot(BeEmpty())
|
|
Expect(info.Version).To(Equal(jellyfinVersion))
|
|
Expect(info.ProductName).To(Equal("Jellyfin Server"))
|
|
Expect(info.ServerName).To(HavePrefix("Navidrome"))
|
|
Expect(info.SupportsLibraryMonitor).To(BeTrue())
|
|
Expect(info.HasPendingRestart).To(BeFalse())
|
|
Expect(info.IsShuttingDown).To(BeFalse())
|
|
})
|
|
|
|
It("advertises a LocalAddress with the request scheme, host and Jellyfin base path", func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/System/Info/Public", nil)
|
|
r.Host = "music.example.com:4599"
|
|
api.getPublicSystemInfo(w, r)
|
|
|
|
var info dto.PublicSystemInfo
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
|
|
// Jellify connecting over HTTP sets its server base URL from LocalAddress; without it the
|
|
// SDK `api` is undefined and sign-in crashes. It must include the /jellyfin mount path.
|
|
Expect(info.LocalAddress).To(Equal("http://music.example.com:4599/jellyfin"))
|
|
})
|
|
|
|
It("responds to ping with the server name as plain text", func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Jellyfin.ServerName = ""
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/System/Ping", nil)
|
|
api.ping(w, r)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("text/plain"))
|
|
// Plain text, not a JSON-quoted string: Jellyfin clients expect the bare server name.
|
|
Expect(w.Body.String()).To(HavePrefix("Navidrome"))
|
|
})
|
|
|
|
DescribeTable("reports the caller's network location on /System/Endpoint",
|
|
func(remoteAddr string, isLocal, isInNetwork bool) {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/System/Endpoint", nil)
|
|
r.RemoteAddr = remoteAddr
|
|
api.getEndpointInfo(w, r)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
// Finamp's connection test only probes for the key's presence, so it must always be emitted.
|
|
Expect(w.Body.String()).To(ContainSubstring(`"IsInNetwork"`))
|
|
var info dto.EndPointInfo
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
|
|
Expect(info.IsLocal).To(Equal(isLocal))
|
|
Expect(info.IsInNetwork).To(Equal(isInNetwork))
|
|
},
|
|
Entry("loopback", "127.0.0.1:12345", true, true),
|
|
Entry("IPv6 loopback", "[::1]:12345", true, true),
|
|
Entry("LAN address", "192.168.1.20:54321", false, true),
|
|
Entry("bare IP, as left by the RealIP middleware", "10.0.0.5", false, true),
|
|
Entry("IPv4-mapped IPv6 LAN address", "[::ffff:172.16.0.9]:80", false, true),
|
|
Entry("IPv6 link-local", "[fe80::1]:80", false, true),
|
|
Entry("IPv6 unique-local", "[fd00::1]:80", false, true),
|
|
// Jellyfin's default LAN set omits 169.254.0.0/16, so we do too.
|
|
Entry("IPv4 link-local", "169.254.1.1:80", false, false),
|
|
Entry("public address", "8.8.8.8:443", false, false),
|
|
Entry("unparseable address", "not-an-ip", false, false),
|
|
)
|
|
|
|
It("reports IsLocal when the caller shares the connection's local address", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/System/Endpoint", nil)
|
|
r.RemoteAddr = "192.168.1.20:54321"
|
|
ctx := context.WithValue(r.Context(), http.LocalAddrContextKey,
|
|
&net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533})
|
|
api.getEndpointInfo(w, r.WithContext(ctx))
|
|
|
|
var info dto.EndPointInfo
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
|
|
Expect(info.IsLocal).To(BeTrue())
|
|
})
|
|
|
|
It("does not report IsLocal for a different host on the same LAN", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/System/Endpoint", nil)
|
|
r.RemoteAddr = "192.168.1.99:54321"
|
|
ctx := context.WithValue(r.Context(), http.LocalAddrContextKey,
|
|
&net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533})
|
|
api.getEndpointInfo(w, r.WithContext(ctx))
|
|
|
|
var info dto.EndPointInfo
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
|
|
Expect(info.IsLocal).To(BeFalse())
|
|
Expect(info.IsInNetwork).To(BeTrue())
|
|
})
|
|
|
|
Context("serverID with a real DataStore", func() {
|
|
var ctx context.Context
|
|
var ds *tests.MockDataStore
|
|
|
|
BeforeEach(func() {
|
|
ctx = context.Background()
|
|
ds = &tests.MockDataStore{}
|
|
})
|
|
|
|
It("persists the generated id so it can be read back by another Router sharing the same DataStore", func() {
|
|
first := &Router{ds: ds}
|
|
id := first.serverID(ctx)
|
|
Expect(id).ToNot(BeEmpty())
|
|
|
|
second := &Router{ds: ds}
|
|
Expect(second.serverID(ctx)).To(Equal(id))
|
|
})
|
|
|
|
It("resolves one id when a Router and a Discovery race on first boot", func() {
|
|
r, d := &Router{ds: ds}, NewDiscovery(ds)
|
|
ids := make([]string, 2)
|
|
var wg sync.WaitGroup
|
|
wg.Go(func() { ids[0] = r.serverID(ctx) })
|
|
wg.Go(func() { ids[1] = d.serverID(ctx) })
|
|
wg.Wait()
|
|
Expect(ids[0]).ToNot(BeEmpty())
|
|
Expect(ids[1]).To(Equal(ids[0]))
|
|
})
|
|
|
|
It("memoizes the id across repeated calls on the same Router", func() {
|
|
r := &Router{ds: ds}
|
|
id := r.serverID(ctx)
|
|
Expect(r.serverID(ctx)).To(Equal(id))
|
|
Expect(r.serverID(ctx)).To(Equal(id))
|
|
})
|
|
|
|
It("does not overwrite or pin over a stored id when the property read fails transiently", func() {
|
|
Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "6ba7b8109dad11d180b400c04fd430c8")).To(Succeed())
|
|
|
|
r := &Router{ds: ds}
|
|
props := ds.Property(ctx).(*tests.MockedPropertyRepo)
|
|
props.Error = errors.New("database is locked")
|
|
degraded := r.serverID(ctx)
|
|
Expect(degraded).ToNot(BeEmpty())
|
|
Expect(degraded).ToNot(Equal("6ba7b8109dad11d180b400c04fd430c8")) // temporary value, not the (unreadable) stored one
|
|
props.Error = nil
|
|
|
|
// Once the DB recovers, the stored id is intact and served again.
|
|
Expect(r.serverID(ctx)).To(Equal("6ba7b8109dad11d180b400c04fd430c8"))
|
|
stored, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(stored).To(Equal("6ba7b8109dad11d180b400c04fd430c8"))
|
|
})
|
|
|
|
It("returns Jellyfin's no-dash GUID form", func() {
|
|
r := &Router{ds: ds}
|
|
Expect(r.serverID(ctx)).To(MatchRegexp("^[0-9a-f]{32}$"))
|
|
})
|
|
|
|
It("strips dashes from an already-persisted id", func() {
|
|
Expect(ds.Property(ctx).Put(
|
|
consts.JellyfinServerIDKey, "1b4e28ba-2fa1-11d2-883f-0016d3cca427")).To(Succeed())
|
|
r := &Router{ds: ds}
|
|
Expect(r.serverID(ctx)).To(Equal("1b4e28ba2fa111d2883f0016d3cca427"))
|
|
})
|
|
})
|
|
})
|