mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-11 03:47:18 +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.
155 lines
5.1 KiB
Go
155 lines
5.1 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/server"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
)
|
|
|
|
// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients and SDKs gate on
|
|
// it (Streamyfin and the Android apps refuse < 10.10), and some parsers need exactly three parts.
|
|
const jellyfinVersion = "12.1.0"
|
|
|
|
func serverName() string {
|
|
if conf.Server.Jellyfin.ServerName != "" {
|
|
return conf.Server.Jellyfin.ServerName
|
|
}
|
|
return fmt.Sprintf("Navidrome %s", consts.Version)
|
|
}
|
|
|
|
func (api *Router) serverID(ctx context.Context) string {
|
|
return resolveServerID(ctx, api.ds, &api.serverIDVal)
|
|
}
|
|
|
|
// Package-level: the Router and Discovery are separate objects and must not persist different ids.
|
|
var serverIDMu sync.Mutex
|
|
|
|
// Clients cache ServerId across sessions, so it is persisted. Only a successful read or write is
|
|
// cached: a transient failure yields a temporary id and retries on the next call.
|
|
func resolveServerID(ctx context.Context, ds model.DataStore, cached *string) string {
|
|
serverIDMu.Lock()
|
|
defer serverIDMu.Unlock()
|
|
if *cached != "" {
|
|
return *cached
|
|
}
|
|
if ds == nil {
|
|
*cached = newServerID()
|
|
return *cached
|
|
}
|
|
id, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey)
|
|
switch {
|
|
case errors.Is(err, model.ErrNotFound):
|
|
id = newServerID()
|
|
if err := ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil {
|
|
log.Error(ctx, "Jellyfin API: could not persist server id", err)
|
|
return id
|
|
}
|
|
case err != nil:
|
|
log.Error(ctx, "Jellyfin API: could not read server id", err)
|
|
return newServerID()
|
|
}
|
|
// Ids persisted before this change are dashed; normalize on read rather than rewriting the DB.
|
|
*cached = strings.ReplaceAll(id, "-", "")
|
|
return *cached
|
|
}
|
|
|
|
// newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")).
|
|
func newServerID() string {
|
|
u := uuid.New()
|
|
return hex.EncodeToString(u[:])
|
|
}
|
|
|
|
func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo {
|
|
return dto.PublicSystemInfo{
|
|
LocalAddress: localAddress(r),
|
|
ServerName: serverName(),
|
|
Version: jellyfinVersion,
|
|
ProductName: "Jellyfin Server",
|
|
Id: api.serverID(r.Context()),
|
|
StartupWizardCompleted: true,
|
|
}
|
|
}
|
|
|
|
// localAddress reconstructs the base URL the client used (scheme/host from the request, honoring
|
|
// X-Forwarded-* headers, plus the mount path), advertised as LocalAddress. Jellify adopts it as
|
|
// its server base URL; without it its SDK api instance is undefined and sign-in crashes.
|
|
func localAddress(r *http.Request) string {
|
|
scheme, host := server.ServerAddress(r)
|
|
return scheme + "://" + host + path.Join(conf.Server.BasePath, consts.URLPathJellyfinAPI)
|
|
}
|
|
|
|
func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) {
|
|
api.ok(w, r, api.publicInfo(r))
|
|
}
|
|
|
|
func (api *Router) getSystemInfo(w http.ResponseWriter, r *http.Request) {
|
|
api.ok(w, r, dto.SystemInfo{
|
|
PublicSystemInfo: api.publicInfo(r),
|
|
SupportsLibraryMonitor: true,
|
|
})
|
|
}
|
|
|
|
// ping answers /System/Ping with a bare plain-text server name (not JSON-quoted): Jellyfin's
|
|
// server does this and clients parse the raw body.
|
|
func (api *Router) ping(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(serverName()))
|
|
}
|
|
|
|
// getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a
|
|
// dual-connection setup's addresses; a missing IsInNetwork reads to it as "not a Jellyfin server".
|
|
func (api *Router) getEndpointInfo(w http.ResponseWriter, r *http.Request) {
|
|
remote := remoteIP(r)
|
|
api.ok(w, r, dto.EndPointInfo{
|
|
IsLocal: isSameMachine(r, remote),
|
|
IsInNetwork: isInLocalNetwork(remote),
|
|
})
|
|
}
|
|
|
|
// isInLocalNetwork mirrors Jellyfin's default LAN set (NetworkManager.UpdateSettings with no
|
|
// LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10.
|
|
func isInLocalNetwork(ip netip.Addr) bool {
|
|
return ip.IsLoopback() || ip.IsPrivate() || (ip.Is6() && ip.IsLinkLocalUnicast())
|
|
}
|
|
|
|
// isSameMachine mirrors Jellyfin's HttpContext.IsLocal(): the caller shares the connection's local
|
|
// address. The local address is missing in tests and unreliable behind a proxy, so fall back to loopback.
|
|
func isSameMachine(r *http.Request, remote netip.Addr) bool {
|
|
local, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr)
|
|
if !ok {
|
|
return remote.IsLoopback()
|
|
}
|
|
return parseIP(local.String()) == remote
|
|
}
|
|
|
|
// remoteIP parses RemoteAddr, which realIPMiddleware may have rewritten to a bare client IP.
|
|
func remoteIP(r *http.Request) netip.Addr {
|
|
return parseIP(r.RemoteAddr)
|
|
}
|
|
|
|
func parseIP(addr string) netip.Addr {
|
|
if h, _, err := net.SplitHostPort(addr); err == nil {
|
|
addr = h
|
|
}
|
|
ip, err := netip.ParseAddr(addr)
|
|
if err != nil {
|
|
return netip.Addr{}
|
|
}
|
|
return ip.Unmap()
|
|
}
|