Refactor routing, changes API URLs (#1171)
* Make authentication part of the server, so it can be reused outside the Native API This commit has broken tests after a rebase * Serve frontend assets from `server`, and not from Native API * Change Native API URL * Fix auth tests * Refactor server authentication * Simplify authProvider, now subsonic token+salt comes from the server * Don't send JWT token to UI when authenticated via Request Header * Enable ReverseProxyWhitelist to be read from environment
This commit is contained in:
+24
-55
@@ -8,80 +8,51 @@ import (
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/httprate"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/ui"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
mux http.Handler
|
||||
broker events.Broker
|
||||
share core.Share
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, broker events.Broker, share core.Share) *Router {
|
||||
return &Router{ds: ds, broker: broker, share: share}
|
||||
r := &Router{ds: ds, broker: broker, share: share}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
}
|
||||
|
||||
func (app *Router) Setup(path string) {
|
||||
app.mux = app.routes(path)
|
||||
}
|
||||
|
||||
func (app *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
app.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (app *Router) routes(path string) http.Handler {
|
||||
func (app *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
if conf.Server.AuthRequestLimit > 0 {
|
||||
log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit,
|
||||
"windowLength", conf.Server.AuthWindowLength)
|
||||
r.Use(server.Authenticator(app.ds))
|
||||
r.Use(server.JWTRefresher)
|
||||
app.R(r, "/user", model.User{}, true)
|
||||
app.R(r, "/song", model.MediaFile{}, true)
|
||||
app.R(r, "/album", model.Album{}, true)
|
||||
app.R(r, "/artist", model.Artist{}, true)
|
||||
app.R(r, "/player", model.Player{}, true)
|
||||
app.R(r, "/playlist", model.Playlist{}, true)
|
||||
app.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||
app.RX(r, "/share", app.share.NewRepository, true)
|
||||
app.RX(r, "/translation", newTranslationRepository, false)
|
||||
|
||||
rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
|
||||
r.With(rateLimiter).Post("/login", Login(app.ds))
|
||||
} else {
|
||||
log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks")
|
||||
app.addPlaylistTrackRoute(r)
|
||||
|
||||
r.Post("/login", Login(app.ds))
|
||||
}
|
||||
|
||||
r.Post("/createAdmin", CreateAdmin(app.ds))
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(mapAuthHeader())
|
||||
r.Use(verifier())
|
||||
r.Use(authenticator(app.ds))
|
||||
app.R(r, "/user", model.User{}, true)
|
||||
app.R(r, "/song", model.MediaFile{}, true)
|
||||
app.R(r, "/album", model.Album{}, true)
|
||||
app.R(r, "/artist", model.Artist{}, true)
|
||||
app.R(r, "/player", model.Player{}, true)
|
||||
app.R(r, "/playlist", model.Playlist{}, true)
|
||||
app.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||
app.RX(r, "/share", app.share.NewRepository, true)
|
||||
app.RX(r, "/translation", newTranslationRepository, false)
|
||||
|
||||
app.addPlaylistTrackRoute(r)
|
||||
|
||||
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
||||
})
|
||||
|
||||
if conf.Server.DevActivityPanel {
|
||||
r.Handle("/events", app.broker)
|
||||
}
|
||||
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
||||
})
|
||||
|
||||
// Serve UI app assets
|
||||
r.Handle("/", serveIndex(app.ds, ui.Assets()))
|
||||
r.Handle("/*", http.StripPrefix(path, http.FileServer(http.FS(ui.Assets()))))
|
||||
if conf.Server.DevActivityPanel {
|
||||
r.Handle("/events", app.broker)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -110,8 +81,6 @@ func (app *Router) RX(r chi.Router, pathPrefix string, constructor rest.Reposito
|
||||
})
|
||||
}
|
||||
|
||||
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
||||
|
||||
func (app *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestApp(t *testing.T) {
|
||||
func TestNativeApi(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/gravatar"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFirstTime = errors.New("no users created")
|
||||
)
|
||||
|
||||
func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
||||
auth.Init(ds)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, err := getCredentialsFromBody(r)
|
||||
if err != nil {
|
||||
log.Error(r, "Parsing request body", err)
|
||||
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
handleLogin(ds, username, password, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) *map[string]interface{} {
|
||||
if !validateIPAgainstList(r.RemoteAddr, conf.Server.ReverseProxyWhitelist) {
|
||||
log.Warn("Ip is not whitelisted for reverse proxy login", "ip", r.RemoteAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
username := r.Header.Get(conf.Server.ReverseProxyUserHeader)
|
||||
|
||||
userRepo := ds.User(r.Context())
|
||||
user, err := userRepo.FindByUsername(username)
|
||||
if user == nil || err != nil {
|
||||
log.Warn("User passed in header not found", "user", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = userRepo.UpdateLastLoginAt(user.ID)
|
||||
if err != nil {
|
||||
log.Error("Could not update LastLoginAt", "user", username, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
tokenString, err := auth.CreateToken(user)
|
||||
if err != nil {
|
||||
log.Error("Could not create token", "user", username, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
payload := buildPayload(user, tokenString)
|
||||
|
||||
bytes := make([]byte, 3)
|
||||
_, err = rand.Read(bytes)
|
||||
if err != nil {
|
||||
log.Error("Could not create subsonic salt", "user", username, err)
|
||||
return nil
|
||||
}
|
||||
salt := hex.EncodeToString(bytes)
|
||||
payload["subsonicSalt"] = salt
|
||||
|
||||
h := md5.New()
|
||||
_, err = io.WriteString(h, user.Password+salt)
|
||||
if err != nil {
|
||||
log.Error("Could not create subsonic token", "user", username, err)
|
||||
return nil
|
||||
}
|
||||
payload["subsonicToken"] = hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
return &payload
|
||||
}
|
||||
|
||||
func handleLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) {
|
||||
user, err := validateLogin(ds.User(r.Context()), username, password)
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again")
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
log.Warn(r, "Unsuccessful login", "username", username, "request", r.Header)
|
||||
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
tokenString, err := auth.CreateToken(user)
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
|
||||
return
|
||||
}
|
||||
payload := buildPayload(user, tokenString)
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func buildPayload(user *model.User, tokenString string) map[string]interface{} {
|
||||
payload := map[string]interface{}{
|
||||
"message": "User '" + user.UserName + "' authenticated successfully",
|
||||
"token": tokenString,
|
||||
"id": user.ID,
|
||||
"name": user.Name,
|
||||
"username": user.UserName,
|
||||
"isAdmin": user.IsAdmin,
|
||||
}
|
||||
if conf.Server.EnableGravatar && user.Email != "" {
|
||||
payload["avatar"] = gravatar.Url(user.Email, 50)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func validateIPAgainstList(ip string, comaSeparatedList string) bool {
|
||||
if comaSeparatedList == "" || ip == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if net.ParseIP(ip) == nil {
|
||||
ip, _, _ = net.SplitHostPort(ip)
|
||||
}
|
||||
|
||||
if ip == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
cidrs := strings.Split(comaSeparatedList, ",")
|
||||
testedIP, _, err := net.ParseCIDR(fmt.Sprintf("%s/32", ip))
|
||||
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, cidr := range cidrs {
|
||||
_, ipnet, err := net.ParseCIDR(cidr)
|
||||
if err == nil && ipnet.Contains(testedIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func getCredentialsFromBody(r *http.Request) (username string, password string, err error) {
|
||||
data := make(map[string]string)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err = decoder.Decode(&data); err != nil {
|
||||
log.Error(r, "parsing request body", err)
|
||||
err = errors.New("invalid request payload")
|
||||
return
|
||||
}
|
||||
username = data["username"]
|
||||
password = data["password"]
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
func CreateAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
||||
auth.Init(ds)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, err := getCredentialsFromBody(r)
|
||||
if err != nil {
|
||||
log.Error(r, "parsing request body", err)
|
||||
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
c, err := ds.User(r.Context()).CountAll()
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if c > 0 {
|
||||
_ = rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin")
|
||||
return
|
||||
}
|
||||
err = createDefaultUser(r.Context(), ds, username, password)
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
handleLogin(ds, username, password, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func createDefaultUser(ctx context.Context, ds model.DataStore, username, password string) error {
|
||||
log.Warn("Creating initial user", "user", username)
|
||||
now := time.Now()
|
||||
initialUser := model.User{
|
||||
ID: uuid.NewString(),
|
||||
UserName: username,
|
||||
Name: strings.Title(username),
|
||||
Email: "",
|
||||
NewPassword: password,
|
||||
IsAdmin: true,
|
||||
LastLoginAt: &now,
|
||||
}
|
||||
err := ds.User(ctx).Put(&initialUser)
|
||||
if err != nil {
|
||||
log.Error("Could not create initial user", "user", initialUser, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLogin(userRepo model.UserRepository, userName, password string) (*model.User, error) {
|
||||
u, err := userRepo.FindByUsername(userName)
|
||||
if err == model.ErrNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Password != password {
|
||||
return nil, nil
|
||||
}
|
||||
err = userRepo.UpdateLastLoginAt(u.ID)
|
||||
if err != nil {
|
||||
log.Error("Could not update LastLoginAt", "user", userName)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func contextWithUser(ctx context.Context, ds model.DataStore, token jwt.Token) context.Context {
|
||||
userName := token.Subject()
|
||||
user, _ := ds.User(ctx).FindByUsername(userName)
|
||||
return request.WithUser(ctx, *user)
|
||||
}
|
||||
|
||||
func getToken(ds model.DataStore, ctx context.Context) (jwt.Token, error) {
|
||||
token, claims, err := jwtauth.FromContext(ctx)
|
||||
|
||||
valid := err == nil && token != nil
|
||||
valid = valid && claims["sub"] != nil
|
||||
if valid {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
c, err := ds.User(ctx).CountAll()
|
||||
firstTime := c == 0 && err == nil
|
||||
if firstTime {
|
||||
return nil, ErrFirstTime
|
||||
}
|
||||
return nil, errors.New("invalid authentication")
|
||||
}
|
||||
|
||||
// This method maps the custom authorization header to the default 'Authorization', used by the jwtauth library
|
||||
func mapAuthHeader() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
bearer := r.Header.Get(consts.UIAuthorizationHeader)
|
||||
r.Header.Set("Authorization", bearer)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func verifier() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return jwtauth.Verify(auth.TokenAuth, jwtauth.TokenFromHeader, jwtauth.TokenFromCookie, jwtauth.TokenFromQuery)(next)
|
||||
}
|
||||
}
|
||||
|
||||
func authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
auth.Init(ds)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := getToken(ds, r.Context())
|
||||
if err == ErrFirstTime {
|
||||
_ = rest.RespondWithJSON(w, http.StatusUnauthorized, map[string]string{"message": ErrFirstTime.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
newCtx := contextWithUser(r.Context(), ds, token)
|
||||
newTokenString, err := auth.TouchToken(token)
|
||||
if err != nil {
|
||||
log.Error(r, "signing new token", err)
|
||||
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set(consts.UIAuthorizationHeader, newTokenString)
|
||||
next.ServeHTTP(w, r.WithContext(newCtx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Auth", func() {
|
||||
Describe("Public functions", func() {
|
||||
var ds model.DataStore
|
||||
var req *http.Request
|
||||
var resp *httptest.ResponseRecorder
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
})
|
||||
|
||||
Describe("CreateAdmin", func() {
|
||||
BeforeEach(func() {
|
||||
req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"johndoe", "password":"secret"}`))
|
||||
resp = httptest.NewRecorder()
|
||||
CreateAdmin(ds)(resp, req)
|
||||
})
|
||||
|
||||
It("creates an admin user with the specified password", func() {
|
||||
usr := ds.User(context.TODO())
|
||||
u, err := usr.FindByUsername("johndoe")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(u.Password).ToNot(BeEmpty())
|
||||
Expect(u.IsAdmin).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns the expected payload", func() {
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["isAdmin"]).To(Equal(true))
|
||||
Expect(parsed["username"]).To(Equal("johndoe"))
|
||||
Expect(parsed["name"]).To(Equal("Johndoe"))
|
||||
Expect(parsed["id"]).ToNot(BeEmpty())
|
||||
Expect(parsed["token"]).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
Describe("Login from HTTP headers", func() {
|
||||
fs := os.DirFS("tests/fixtures")
|
||||
|
||||
BeforeEach(func() {
|
||||
req = httptest.NewRequest("GET", "/index.html", nil)
|
||||
req.Header.Add("Remote-User", "janedoe")
|
||||
resp = httptest.NewRecorder()
|
||||
conf.Server.UILoginBackgroundURL = ""
|
||||
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16,2001:4860:4860::/48"
|
||||
})
|
||||
|
||||
It("sets auth data if IPv4 matches whitelist", func() {
|
||||
usr := ds.User(context.TODO())
|
||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
||||
|
||||
req.RemoteAddr = "192.168.0.42:25293"
|
||||
serveIndex(ds, fs)(resp, req)
|
||||
|
||||
config := extractAppConfig(resp.Body.String())
|
||||
parsed := config["auth"].(map[string]interface{})
|
||||
|
||||
Expect(parsed["id"]).To(Equal("111"))
|
||||
})
|
||||
|
||||
It("sets no auth data if IPv4 does not match whitelist", func() {
|
||||
usr := ds.User(context.TODO())
|
||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
||||
|
||||
req.RemoteAddr = "8.8.8.8:25293"
|
||||
serveIndex(ds, fs)(resp, req)
|
||||
|
||||
config := extractAppConfig(resp.Body.String())
|
||||
Expect(config["auth"]).To(BeNil())
|
||||
})
|
||||
|
||||
It("sets auth data if IPv6 matches whitelist", func() {
|
||||
usr := ds.User(context.TODO())
|
||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
||||
|
||||
req.RemoteAddr = "[2001:4860:4860:1234:5678:0000:4242:8888]:25293"
|
||||
serveIndex(ds, fs)(resp, req)
|
||||
|
||||
config := extractAppConfig(resp.Body.String())
|
||||
parsed := config["auth"].(map[string]interface{})
|
||||
|
||||
Expect(parsed["id"]).To(Equal("111"))
|
||||
})
|
||||
|
||||
It("sets no auth data if IPv6 does not match whitelist", func() {
|
||||
usr := ds.User(context.TODO())
|
||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
||||
|
||||
req.RemoteAddr = "[5005:0:3003]:25293"
|
||||
serveIndex(ds, fs)(resp, req)
|
||||
|
||||
config := extractAppConfig(resp.Body.String())
|
||||
Expect(config["auth"]).To(BeNil())
|
||||
})
|
||||
|
||||
It("sets auth data if user exists", func() {
|
||||
req.RemoteAddr = "192.168.0.42:25293"
|
||||
|
||||
usr := ds.User(context.TODO())
|
||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
||||
|
||||
serveIndex(ds, fs)(resp, req)
|
||||
|
||||
config := extractAppConfig(resp.Body.String())
|
||||
parsed := config["auth"].(map[string]interface{})
|
||||
|
||||
Expect(parsed["id"]).To(Equal("111"))
|
||||
Expect(parsed["isAdmin"]).To(BeFalse())
|
||||
Expect(parsed["name"]).To(Equal("Jane"))
|
||||
Expect(parsed["username"]).To(Equal("janedoe"))
|
||||
Expect(parsed["token"]).ToNot(BeEmpty())
|
||||
Expect(parsed["subsonicSalt"]).ToNot(BeEmpty())
|
||||
Expect(parsed["subsonicToken"]).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
})
|
||||
Describe("Login", func() {
|
||||
BeforeEach(func() {
|
||||
req = httptest.NewRequest("POST", "/login", strings.NewReader(`{"username":"janedoe", "password":"abc123"}`))
|
||||
resp = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
It("fails if user does not exist", func() {
|
||||
Login(ds)(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("logs in successfully if user exists", func() {
|
||||
usr := ds.User(context.TODO())
|
||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
||||
|
||||
Login(ds)(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["isAdmin"]).To(Equal(false))
|
||||
Expect(parsed["username"]).To(Equal("janedoe"))
|
||||
Expect(parsed["name"]).To(Equal("Jane"))
|
||||
Expect(parsed["id"]).ToNot(BeEmpty())
|
||||
Expect(parsed["token"]).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("mapAuthHeader", func() {
|
||||
It("maps the custom header to Authorization header", func() {
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
r.Header.Set(consts.UIAuthorizationHeader, "test authorization bearer")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
mapAuthHeader()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Expect(r.Header.Get("Authorization")).To(Equal("test authorization bearer"))
|
||||
w.WriteHeader(200)
|
||||
})).ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(200))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
||||
|
||||
func getPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
// Add a middleware to capture the playlistId
|
||||
wrapper := func(handler restHandler) http.HandlerFunc {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// Injects the config in the `index.html` template
|
||||
func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
|
||||
policy := bluemonday.UGCPolicy()
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
base := path.Join(conf.Server.BaseURL, consts.URLPathUI)
|
||||
if r.URL.Path == base {
|
||||
http.Redirect(w, r, base+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
c, err := ds.User(r.Context()).CountAll()
|
||||
firstTime := c == 0 && err == nil
|
||||
|
||||
t, err := getIndexTemplate(r, fs)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
appConfig := map[string]interface{}{
|
||||
"version": consts.Version(),
|
||||
"firstTime": firstTime,
|
||||
"baseURL": policy.Sanitize(strings.TrimSuffix(conf.Server.BaseURL, "/")),
|
||||
"loginBackgroundURL": policy.Sanitize(conf.Server.UILoginBackgroundURL),
|
||||
"welcomeMessage": policy.Sanitize(conf.Server.UIWelcomeMessage),
|
||||
"enableTranscodingConfig": conf.Server.EnableTranscodingConfig,
|
||||
"enableDownloads": conf.Server.EnableDownloads,
|
||||
"enableFavourites": conf.Server.EnableFavourites,
|
||||
"enableStarRating": conf.Server.EnableStarRating,
|
||||
"defaultTheme": conf.Server.DefaultTheme,
|
||||
"gaTrackingId": conf.Server.GATrackingID,
|
||||
"losslessFormats": strings.ToUpper(strings.Join(consts.LosslessFormats, ",")),
|
||||
"devActivityPanel": conf.Server.DevActivityPanel,
|
||||
"devFastAccessCoverArt": conf.Server.DevFastAccessCoverArt,
|
||||
"enableUserEditing": conf.Server.EnableUserEditing,
|
||||
"devEnableShare": conf.Server.DevEnableShare,
|
||||
}
|
||||
auth := handleLoginFromHeaders(ds, r)
|
||||
if auth != nil {
|
||||
appConfig["auth"] = *auth
|
||||
}
|
||||
j, err := json.Marshal(appConfig)
|
||||
if err != nil {
|
||||
log.Error(r, "Error converting config to JSON", "config", appConfig, err)
|
||||
} else {
|
||||
log.Trace(r, "Injecting config in index.html", "config", string(j))
|
||||
}
|
||||
|
||||
log.Debug("UI configuration", "appConfig", appConfig)
|
||||
version := consts.Version()
|
||||
if version != "dev" {
|
||||
version = "v" + version
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"AppConfig": string(j),
|
||||
"Version": version,
|
||||
}
|
||||
err = t.Execute(w, data)
|
||||
if err != nil {
|
||||
log.Error(r, "Could not execute `index.html` template", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getIndexTemplate(r *http.Request, fs fs.FS) (*template.Template, error) {
|
||||
t := template.New("initial state")
|
||||
indexHtml, err := fs.Open("index.html")
|
||||
if err != nil {
|
||||
log.Error(r, "Could not find `index.html` template", err)
|
||||
return nil, err
|
||||
}
|
||||
indexStr, err := ioutil.ReadAll(indexHtml)
|
||||
if err != nil {
|
||||
log.Error(r, "Could not read from `index.html`", err)
|
||||
return nil, err
|
||||
}
|
||||
t, err = t.Parse(string(indexStr))
|
||||
if err != nil {
|
||||
log.Error(r, "Error parsing `index.html`", err)
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("serveIndex", func() {
|
||||
var ds model.DataStore
|
||||
mockUser := &mockedUserRepo{}
|
||||
fs := os.DirFS("tests/fixtures")
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{MockedUser: mockUser}
|
||||
conf.Server.UILoginBackgroundURL = ""
|
||||
})
|
||||
|
||||
It("redirects bare /app path to /app/", func() {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(302))
|
||||
Expect(w.Header().Get("Location")).To(Equal("/app/"))
|
||||
})
|
||||
|
||||
It("adds app_config to index.html", func() {
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(200))
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(BeAssignableToTypeOf(map[string]interface{}{}))
|
||||
})
|
||||
|
||||
It("sets firstTime = true when User table is empty", func() {
|
||||
mockUser.empty = true
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("firstTime", true))
|
||||
})
|
||||
|
||||
It("sets firstTime = false when User table is not empty", func() {
|
||||
mockUser.empty = false
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("firstTime", false))
|
||||
})
|
||||
|
||||
It("sets baseURL", func() {
|
||||
conf.Server.BaseURL = "base_url_test"
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("baseURL", "base_url_test"))
|
||||
})
|
||||
|
||||
It("sets the uiLoginBackgroundURL", func() {
|
||||
conf.Server.UILoginBackgroundURL = "my_background_url"
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "my_background_url"))
|
||||
})
|
||||
|
||||
It("sets the welcomeMessage", func() {
|
||||
conf.Server.UIWelcomeMessage = "Hello"
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("welcomeMessage", "Hello"))
|
||||
})
|
||||
|
||||
It("sets the enableTranscodingConfig", func() {
|
||||
conf.Server.EnableTranscodingConfig = true
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("enableTranscodingConfig", true))
|
||||
})
|
||||
|
||||
It("sets the enableDownloads", func() {
|
||||
conf.Server.EnableDownloads = true
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("enableDownloads", true))
|
||||
})
|
||||
|
||||
It("sets the enableLoved", func() {
|
||||
conf.Server.EnableFavourites = true
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("enableFavourites", true))
|
||||
})
|
||||
|
||||
It("sets the enableStarRating", func() {
|
||||
conf.Server.EnableStarRating = true
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("enableStarRating", true))
|
||||
})
|
||||
|
||||
It("sets the defaultTheme", func() {
|
||||
conf.Server.DefaultTheme = "Light"
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("defaultTheme", "Light"))
|
||||
})
|
||||
|
||||
It("sets the gaTrackingId", func() {
|
||||
conf.Server.GATrackingID = "UA-12345"
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("gaTrackingId", "UA-12345"))
|
||||
})
|
||||
|
||||
It("sets the version", func() {
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("version", consts.Version()))
|
||||
})
|
||||
|
||||
It("sets the losslessFormats", func() {
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
expected := strings.ToUpper(strings.Join(consts.LosslessFormats, ","))
|
||||
Expect(config).To(HaveKeyWithValue("losslessFormats", expected))
|
||||
})
|
||||
|
||||
It("sets the enableUserEditing", func() {
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("enableUserEditing", true))
|
||||
})
|
||||
|
||||
It("sets the devEnableShare", func() {
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("devEnableShare", false))
|
||||
})
|
||||
})
|
||||
|
||||
var appConfigRegex = regexp.MustCompile(`(?m)window.__APP_CONFIG__="([^"]*)`)
|
||||
|
||||
func extractAppConfig(body string) map[string]interface{} {
|
||||
config := make(map[string]interface{})
|
||||
match := appConfigRegex.FindStringSubmatch(body)
|
||||
if match == nil {
|
||||
return config
|
||||
}
|
||||
str, err := strconv.Unquote("\"" + match[1] + "\"")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("%s: %s", match[1], err))
|
||||
}
|
||||
if err := json.Unmarshal([]byte(str), &config); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
type mockedUserRepo struct {
|
||||
model.UserRepository
|
||||
empty bool
|
||||
}
|
||||
|
||||
func (u *mockedUserRepo) CountAll(...model.QueryOptions) (int64, error) {
|
||||
if u.empty {
|
||||
return 0, nil
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
Reference in New Issue
Block a user