mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-14 06:08:46 +01:00
Merge branch 'master' into feat-podcast-ux-improvements/5420
This commit is contained in:
commit
6a1b201542
148 changed files with 5037 additions and 487 deletions
|
|
@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
|
|||
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ var _ = Describe("Library API", func() {
|
|||
conf.Server.EnableSharing = false
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ var _ = Describe("Metadata API", func() {
|
|||
}
|
||||
auth.Init(ds)
|
||||
provider = &fakeProvider{}
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ var _ = Describe("Missing Files Endpoint", func() {
|
|||
token, err = auth.CreateToken(&user)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil))
|
||||
router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil))
|
||||
})
|
||||
|
||||
DescribeTable("GET /missing/{id}",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
playlistsvc "github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/quickconnect"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
|
|
@ -48,10 +49,11 @@ type Router struct {
|
|||
pluginManager PluginManager
|
||||
imgUpload artwork.Uploader
|
||||
provider external.Provider
|
||||
quickConnect quickconnect.QuickConnect
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider}
|
||||
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider, quickConnect quickconnect.QuickConnect) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider, quickConnect: quickConnect}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
}
|
||||
|
|
@ -89,6 +91,7 @@ func (api *Router) routes() http.Handler {
|
|||
api.addMissingFilesRoute(r)
|
||||
api.addKeepAliveRoute(r)
|
||||
api.addInsightsRoute(r)
|
||||
api.addQuickConnectRoute(r)
|
||||
|
||||
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
||||
api.addInspectRoute(r)
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
|
|||
mfRepo.SetData(testSongs)
|
||||
|
||||
// Create the native API router and wrap it with the JWTVerifier middleware
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
|||
err := userRepo.Put(&testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
|
||||
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() {
|
|||
ds = &tests.MockDataStore{}
|
||||
mockManager = &tests.MockPluginManager{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
|
|
|||
76
server/nativeapi/quickconnect.go
Normal file
76
server/nativeapi/quickconnect.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/quickconnect"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
)
|
||||
|
||||
type quickConnectDevice struct {
|
||||
AppName string `json:"appName"`
|
||||
AppVersion string `json:"appVersion"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
}
|
||||
|
||||
func (api *Router) addQuickConnectRoute(r chi.Router) {
|
||||
if !quickconnect.Enabled() {
|
||||
return
|
||||
}
|
||||
r.Route("/quickconnect", func(r chi.Router) {
|
||||
// Throttled like login so a signed-in user cannot enumerate other people's pending codes.
|
||||
if conf.Server.AuthRequestLimit > 0 {
|
||||
r.Use(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength))
|
||||
}
|
||||
r.Get("/", lookupQuickConnect(api.quickConnect))
|
||||
r.Post("/authorize", authorizeQuickConnect(api.quickConnect))
|
||||
})
|
||||
}
|
||||
|
||||
func lookupQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := qc.Lookup(r.URL.Query().Get("code"))
|
||||
writeQuickConnectResult(w, r, req, err)
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
user, _ := request.UserFrom(ctx)
|
||||
req, err := qc.Authorize(body.Code, user.ID)
|
||||
if err == nil {
|
||||
log.Info(ctx, "Quick Connect sign-in approved", "username", user.UserName, "client", req.Device.App, "device", req.Device.Name)
|
||||
}
|
||||
writeQuickConnectResult(w, r, req, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeQuickConnectResult(w http.ResponseWriter, r *http.Request, req quickconnect.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
http.Error(w, "Unknown code", http.StatusNotFound)
|
||||
case errors.Is(err, quickconnect.ErrAlreadyAuthorized):
|
||||
http.Error(w, "Code already used", http.StatusConflict)
|
||||
case err != nil:
|
||||
log.Error(r.Context(), "Quick Connect failed", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
default:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(quickConnectDevice{AppName: req.Device.App, AppVersion: req.Device.AppVersion, DeviceName: req.Device.Name})
|
||||
}
|
||||
}
|
||||
148
server/nativeapi/quickconnect_test.go
Normal file
148
server/nativeapi/quickconnect_test.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/quickconnect"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Quick Connect endpoints", func() {
|
||||
var (
|
||||
qc quickconnect.QuickConnect
|
||||
pending quickconnect.Request
|
||||
user = model.User{ID: "u1", UserName: "alice"}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
qc = quickconnect.New()
|
||||
var err error
|
||||
pending, err = qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
asUser := func(r *http.Request) *http.Request {
|
||||
return r.WithContext(request.WithUser(r.Context(), user))
|
||||
}
|
||||
decode := func(w *httptest.ResponseRecorder) quickConnectDevice {
|
||||
var res quickConnectDevice
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
||||
return res
|
||||
}
|
||||
finamp := quickConnectDevice{AppName: "Finamp", AppVersion: "1.0.0", DeviceName: "Pixel 7"}
|
||||
|
||||
Describe("GET /quickconnect", func() {
|
||||
lookup := func(code string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
lookupQuickConnect(qc)(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+code, nil)))
|
||||
return w
|
||||
}
|
||||
|
||||
It("describes the device waiting for the code", func() {
|
||||
w := lookup(pending.Code)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(decode(w)).To(Equal(finamp))
|
||||
})
|
||||
|
||||
It("does not approve the code", func() {
|
||||
lookup(pending.Code)
|
||||
got, _ := qc.Status(pending.Secret)
|
||||
Expect(got.Authorized()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns 404 for an unknown code", func() {
|
||||
Expect(lookup("000000").Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns 409 for a code that is already approved", func() {
|
||||
_, _ = qc.Authorize(pending.Code, "someone")
|
||||
Expect(lookup(pending.Code).Code).To(Equal(http.StatusConflict))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("POST /quickconnect/authorize", func() {
|
||||
authorize := func(body string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
authorizeQuickConnect(qc)(w, asUser(httptest.NewRequest("POST", "/quickconnect/authorize", strings.NewReader(body))))
|
||||
return w
|
||||
}
|
||||
|
||||
It("approves the code for the signed-in user", func() {
|
||||
w := authorize(`{"code":"` + pending.Code + `"}`)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(decode(w)).To(Equal(finamp))
|
||||
got, _ := qc.Status(pending.Secret)
|
||||
Expect(got.UserID).To(Equal(user.ID))
|
||||
})
|
||||
|
||||
It("returns 404 for an unknown code", func() {
|
||||
Expect(authorize(`{"code":"000000"}`).Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns 409 for a code that is already approved", func() {
|
||||
_, _ = qc.Authorize(pending.Code, "someone")
|
||||
Expect(authorize(`{"code":"` + pending.Code + `"}`).Code).To(Equal(http.StatusConflict))
|
||||
})
|
||||
|
||||
It("returns 400 for a bad body", func() {
|
||||
Expect(authorize(`nope`).Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("route registration", func() {
|
||||
serve := func() int {
|
||||
r := chi.NewRouter()
|
||||
(&Router{quickConnect: qc}).addQuickConnectRoute(r)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+pending.Code, nil)))
|
||||
return w.Code
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Jellyfin.Enabled = true
|
||||
conf.Server.Jellyfin.QuickConnect = true
|
||||
})
|
||||
|
||||
It("mounts the routes when Jellyfin Quick Connect is on", func() {
|
||||
Expect(serve()).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("rate-limits code attempts when a login limit is configured", func() {
|
||||
conf.Server.AuthRequestLimit = 2
|
||||
conf.Server.AuthWindowLength = time.Minute
|
||||
r := chi.NewRouter()
|
||||
(&Router{quickConnect: qc}).addQuickConnectRoute(r)
|
||||
attempt := func() int {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/quickconnect?code=000000", nil)
|
||||
req.RemoteAddr = "10.0.0.1:1234"
|
||||
r.ServeHTTP(w, asUser(req))
|
||||
return w.Code
|
||||
}
|
||||
Expect(attempt()).To(Equal(http.StatusNotFound))
|
||||
Expect(attempt()).To(Equal(http.StatusNotFound))
|
||||
Expect(attempt()).To(Equal(http.StatusTooManyRequests))
|
||||
})
|
||||
|
||||
It("skips the routes when the Jellyfin API is off", func() {
|
||||
conf.Server.Jellyfin.Enabled = false
|
||||
Expect(serve()).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("skips the routes when Quick Connect is off", func() {
|
||||
conf.Server.Jellyfin.QuickConnect = false
|
||||
Expect(serve()).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -45,7 +45,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func()
|
|||
auth.Init(ds)
|
||||
|
||||
userService := core.NewUser(ds, noopPluginUnloader{})
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue