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:
Deluan Quintão
2021-06-13 12:46:36 -04:00
committed by GitHub
parent bed2f017af
commit 03efc48137
16 changed files with 298 additions and 317 deletions
+24 -55
View File
@@ -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) {
+1 -1
View File
@@ -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)
-309
View File
@@ -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))
})
}
}
-179
View File
@@ -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))
})
})
})
+2
View File
@@ -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 {
-99
View File
@@ -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
}
-242
View File
@@ -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
}