mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 18:37:09 +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.
425 lines
12 KiB
Go
425 lines
12 KiB
Go
package nativeapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/server"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Library API", func() {
|
|
var ds model.DataStore
|
|
var router http.Handler
|
|
var adminUser, regularUser model.User
|
|
var library1, library2 model.Library
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.EnableSharing = false
|
|
ds = &tests.MockDataStore{}
|
|
auth.Init(ds)
|
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
|
router = server.JWTVerifier(nativeRouter)
|
|
|
|
// Create test users
|
|
adminUser = model.User{
|
|
ID: "admin-1",
|
|
UserName: "admin",
|
|
Name: "Admin User",
|
|
IsAdmin: true,
|
|
NewPassword: "adminpass",
|
|
}
|
|
regularUser = model.User{
|
|
ID: "user-1",
|
|
UserName: "regular",
|
|
Name: "Regular User",
|
|
IsAdmin: false,
|
|
NewPassword: "userpass",
|
|
}
|
|
|
|
// Create test libraries
|
|
library1 = model.Library{
|
|
ID: 1,
|
|
Name: "Test Library 1",
|
|
Path: "/music/library1",
|
|
}
|
|
library2 = model.Library{
|
|
ID: 2,
|
|
Name: "Test Library 2",
|
|
Path: "/music/library2",
|
|
}
|
|
|
|
// Store in mock datastore
|
|
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
|
|
Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed())
|
|
Expect(ds.Library(context.TODO()).Put(&library1)).To(Succeed())
|
|
Expect(ds.Library(context.TODO()).Put(&library2)).To(Succeed())
|
|
})
|
|
|
|
Describe("Library CRUD Operations", func() {
|
|
Context("as admin user", func() {
|
|
var adminToken string
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
adminToken, err = auth.CreateToken(&adminUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
Describe("GET /api/library", func() {
|
|
It("returns all libraries", func() {
|
|
req := createAuthenticatedRequest("GET", "/library", nil, adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
|
|
var libraries []model.Library
|
|
err := json.Unmarshal(w.Body.Bytes(), &libraries)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(2))
|
|
Expect(libraries[0].Name).To(Equal("Test Library 1"))
|
|
Expect(libraries[1].Name).To(Equal("Test Library 2"))
|
|
})
|
|
})
|
|
|
|
Describe("GET /api/library/{id}", func() {
|
|
It("returns a specific library", func() {
|
|
req := createAuthenticatedRequest("GET", "/library/1", nil, adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
|
|
var library model.Library
|
|
err := json.Unmarshal(w.Body.Bytes(), &library)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(library.Name).To(Equal("Test Library 1"))
|
|
Expect(library.Path).To(Equal("/music/library1"))
|
|
})
|
|
|
|
It("returns 404 for non-existent library", func() {
|
|
req := createAuthenticatedRequest("GET", "/library/999", nil, adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 400 for invalid library ID", func() {
|
|
req := createAuthenticatedRequest("GET", "/library/invalid", nil, adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("POST /api/library", func() {
|
|
It("creates a new library", func() {
|
|
newLibrary := model.Library{
|
|
Name: "New Library",
|
|
Path: "/music/new",
|
|
}
|
|
body, _ := json.Marshal(newLibrary)
|
|
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
})
|
|
|
|
It("validates required fields", func() {
|
|
invalidLibrary := model.Library{
|
|
Name: "", // Missing name
|
|
Path: "/music/invalid",
|
|
}
|
|
body, _ := json.Marshal(invalidLibrary)
|
|
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
Expect(w.Body.String()).To(ContainSubstring("library name is required"))
|
|
})
|
|
|
|
It("validates path field", func() {
|
|
invalidLibrary := model.Library{
|
|
Name: "Valid Name",
|
|
Path: "", // Missing path
|
|
}
|
|
body, _ := json.Marshal(invalidLibrary)
|
|
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
Expect(w.Body.String()).To(ContainSubstring("library path is required"))
|
|
})
|
|
})
|
|
|
|
Describe("PUT /api/library/{id}", func() {
|
|
It("updates an existing library", func() {
|
|
updatedLibrary := model.Library{
|
|
Name: "Updated Library 1",
|
|
Path: "/music/updated",
|
|
}
|
|
body, _ := json.Marshal(updatedLibrary)
|
|
req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
|
|
var updated model.Library
|
|
err := json.Unmarshal(w.Body.Bytes(), &updated)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(updated.ID).To(Equal(1))
|
|
Expect(updated.Name).To(Equal("Updated Library 1"))
|
|
Expect(updated.Path).To(Equal("/music/updated"))
|
|
})
|
|
|
|
It("validates required fields on update", func() {
|
|
invalidLibrary := model.Library{
|
|
Name: "",
|
|
Path: "/music/path",
|
|
}
|
|
body, _ := json.Marshal(invalidLibrary)
|
|
req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
})
|
|
|
|
Describe("DELETE /api/library/{id}", func() {
|
|
It("deletes an existing library", func() {
|
|
req := createAuthenticatedRequest("DELETE", "/library/1", nil, adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
})
|
|
|
|
It("returns 404 for non-existent library", func() {
|
|
req := createAuthenticatedRequest("DELETE", "/library/999", nil, adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
})
|
|
|
|
Context("as regular user", func() {
|
|
var userToken string
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
userToken, err = auth.CreateToken(®ularUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("denies access to library management endpoints", func() {
|
|
endpoints := []string{
|
|
"GET /library",
|
|
"POST /library",
|
|
"GET /library/1",
|
|
"PUT /library/1",
|
|
"DELETE /library/1",
|
|
}
|
|
|
|
for _, endpoint := range endpoints {
|
|
parts := strings.Split(endpoint, " ")
|
|
method, path := parts[0], parts[1]
|
|
|
|
req := createAuthenticatedRequest(method, path, nil, userToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
|
}
|
|
})
|
|
})
|
|
|
|
Context("without authentication", func() {
|
|
It("denies access to library management endpoints", func() {
|
|
req := createUnauthenticatedRequest("GET", "/library", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("User-Library Association Operations", func() {
|
|
Context("as admin user", func() {
|
|
var adminToken string
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
adminToken, err = auth.CreateToken(&adminUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
Describe("GET /api/user/{id}/library", func() {
|
|
It("returns user's libraries", func() {
|
|
// Set up user libraries
|
|
err := ds.User(context.TODO()).SetUserLibraries(regularUser.ID, []int{1, 2})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
|
|
var libraries []model.Library
|
|
err = json.Unmarshal(w.Body.Bytes(), &libraries)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(2))
|
|
})
|
|
|
|
It("returns 404 for non-existent user", func() {
|
|
req := createAuthenticatedRequest("GET", "/user/non-existent/library", nil, adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("PUT /api/user/{id}/library", func() {
|
|
It("sets user's libraries", func() {
|
|
request := map[string][]int{
|
|
"libraryIds": {1, 2},
|
|
}
|
|
body, _ := json.Marshal(request)
|
|
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
|
|
var libraries []model.Library
|
|
err := json.Unmarshal(w.Body.Bytes(), &libraries)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(2))
|
|
})
|
|
|
|
It("validates library IDs exist", func() {
|
|
request := map[string][]int{
|
|
"libraryIds": {999}, // Non-existent library
|
|
}
|
|
body, _ := json.Marshal(request)
|
|
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
Expect(w.Body.String()).To(ContainSubstring("library ID 999 does not exist"))
|
|
})
|
|
|
|
It("requires at least one library for regular users", func() {
|
|
request := map[string][]int{
|
|
"libraryIds": {}, // Empty libraries
|
|
}
|
|
body, _ := json.Marshal(request)
|
|
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
Expect(w.Body.String()).To(ContainSubstring("at least one library must be assigned"))
|
|
})
|
|
|
|
It("prevents manual assignment to admin users", func() {
|
|
request := map[string][]int{
|
|
"libraryIds": {1},
|
|
}
|
|
body, _ := json.Marshal(request)
|
|
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", adminUser.ID), bytes.NewBuffer(body), adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
Expect(w.Body.String()).To(ContainSubstring("cannot manually assign libraries to admin users"))
|
|
})
|
|
})
|
|
})
|
|
|
|
Context("as regular user", func() {
|
|
var userToken string
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
userToken, err = auth.CreateToken(®ularUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("denies access to user-library association endpoints", func() {
|
|
req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, userToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// Helper functions
|
|
|
|
func createAuthenticatedRequest(method, path string, body *bytes.Buffer, token string) *http.Request {
|
|
if body == nil {
|
|
body = &bytes.Buffer{}
|
|
}
|
|
req := httptest.NewRequest(method, path, body)
|
|
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return req
|
|
}
|
|
|
|
func createUnauthenticatedRequest(method, path string, body *bytes.Buffer) *http.Request {
|
|
if body == nil {
|
|
body = &bytes.Buffer{}
|
|
}
|
|
req := httptest.NewRequest(method, path, body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return req
|
|
}
|