update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
132
server/nativeapi/config.go
Normal file
132
server/nativeapi/config.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
// sensitiveFieldsPartialMask contains configuration field names that should be redacted
|
||||
// using partial masking (first and last character visible, middle replaced with *).
|
||||
// For values with 7+ characters: "secretvalue123" becomes "s***********3"
|
||||
// For values with <7 characters: "short" becomes "****"
|
||||
// Add field paths using dot notation (e.g., "LastFM.ApiKey", "Spotify.Secret")
|
||||
var sensitiveFieldsPartialMask = []string{
|
||||
"LastFM.ApiKey",
|
||||
"LastFM.Secret",
|
||||
"Prometheus.MetricsPath",
|
||||
"Spotify.ID",
|
||||
"Spotify.Secret",
|
||||
"DevAutoLoginUsername",
|
||||
}
|
||||
|
||||
// sensitiveFieldsFullMask contains configuration field names that should always be
|
||||
// completely masked with "****" regardless of their length.
|
||||
// Add field paths using dot notation for any fields that should never show any content.
|
||||
var sensitiveFieldsFullMask = []string{
|
||||
"DevAutoCreateAdminPassword",
|
||||
"PasswordEncryptionKey",
|
||||
"Prometheus.Password",
|
||||
}
|
||||
|
||||
type configResponse struct {
|
||||
ID string `json:"id"`
|
||||
ConfigFile string `json:"configFile"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
func redactValue(key string, value string) string {
|
||||
// Return empty values as-is
|
||||
if len(value) == 0 {
|
||||
return value
|
||||
}
|
||||
|
||||
// Check if this field should be fully masked
|
||||
for _, field := range sensitiveFieldsFullMask {
|
||||
if field == key {
|
||||
return "****"
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this field should be partially masked
|
||||
for _, field := range sensitiveFieldsPartialMask {
|
||||
if field == key {
|
||||
if len(value) < 7 {
|
||||
return "****"
|
||||
}
|
||||
// Show first and last character with * in between
|
||||
return string(value[0]) + strings.Repeat("*", len(value)-2) + string(value[len(value)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// Return original value if not sensitive
|
||||
return value
|
||||
}
|
||||
|
||||
// applySensitiveFieldMasking recursively applies masking to sensitive fields in the configuration map
|
||||
func applySensitiveFieldMasking(ctx context.Context, config map[string]interface{}, prefix string) {
|
||||
for key, value := range config {
|
||||
fullKey := key
|
||||
if prefix != "" {
|
||||
fullKey = prefix + "." + key
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
// Recursively process nested maps
|
||||
applySensitiveFieldMasking(ctx, v, fullKey)
|
||||
case string:
|
||||
// Apply masking to string values
|
||||
config[key] = redactValue(fullKey, v)
|
||||
default:
|
||||
// For other types (numbers, booleans, etc.), convert to string and check for masking
|
||||
if str := fmt.Sprint(v); str != "" {
|
||||
masked := redactValue(fullKey, str)
|
||||
if masked != str {
|
||||
// Only replace if masking was applied
|
||||
config[key] = masked
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getConfig(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Marshal the actual configuration struct to preserve original field names
|
||||
configBytes, err := json.Marshal(*conf.Server)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error marshaling config", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Unmarshal back to map to get the structure with proper field names
|
||||
var configMap map[string]interface{}
|
||||
err = json.Unmarshal(configBytes, &configMap)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error unmarshaling config to map", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply sensitive field masking
|
||||
applySensitiveFieldMasking(ctx, configMap, "")
|
||||
|
||||
resp := configResponse{
|
||||
ID: "config",
|
||||
ConfigFile: conf.Server.ConfigFile,
|
||||
Config: configMap,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Error(ctx, "Error encoding config response", err)
|
||||
}
|
||||
}
|
||||
227
server/nativeapi/config_test.go
Normal file
227
server/nativeapi/config_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"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("Config API", func() {
|
||||
var ds model.DataStore
|
||||
var router http.Handler
|
||||
var adminUser, regularUser model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), 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",
|
||||
}
|
||||
|
||||
// Store in mock datastore
|
||||
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
|
||||
Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed())
|
||||
})
|
||||
|
||||
Describe("GET /api/config", func() {
|
||||
Context("as admin user", func() {
|
||||
var adminToken string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
adminToken, err = auth.CreateToken(&adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns config successfully", func() {
|
||||
req := createAuthenticatedConfigRequest(adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
Expect(resp.ID).To(Equal("config"))
|
||||
Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile))
|
||||
Expect(resp.Config).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("redacts sensitive fields", func() {
|
||||
conf.Server.LastFM.ApiKey = "secretapikey123"
|
||||
conf.Server.Spotify.Secret = "spotifysecret456"
|
||||
conf.Server.PasswordEncryptionKey = "encryptionkey789"
|
||||
conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
|
||||
conf.Server.Prometheus.Password = "prometheuspass"
|
||||
|
||||
req := createAuthenticatedConfigRequest(adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
|
||||
// Check LastFM.ApiKey (partially masked)
|
||||
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
|
||||
|
||||
// Check Spotify.Secret (partially masked)
|
||||
spotify, ok := resp.Config["Spotify"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(spotify["Secret"]).To(Equal("s**************6"))
|
||||
|
||||
// Check PasswordEncryptionKey (fully masked)
|
||||
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
|
||||
|
||||
// Check DevAutoCreateAdminPassword (fully masked)
|
||||
Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
|
||||
|
||||
// Check Prometheus.Password (fully masked)
|
||||
prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(prometheus["Password"]).To(Equal("****"))
|
||||
})
|
||||
|
||||
It("handles empty sensitive values", func() {
|
||||
conf.Server.LastFM.ApiKey = ""
|
||||
conf.Server.PasswordEncryptionKey = ""
|
||||
|
||||
req := createAuthenticatedConfigRequest(adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
|
||||
// Check LastFM.ApiKey - should be preserved because it's sensitive
|
||||
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(lastfm["ApiKey"]).To(Equal(""))
|
||||
|
||||
// Empty sensitive values should remain empty - should be preserved because it's sensitive
|
||||
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
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 with forbidden status", func() {
|
||||
req := createAuthenticatedConfigRequest(userToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
|
||||
Context("without authentication", func() {
|
||||
It("denies access with unauthorized status", func() {
|
||||
req := createUnauthenticatedConfigRequest("GET", "/config/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("redactValue function", func() {
|
||||
It("partially masks long sensitive values", func() {
|
||||
Expect(redactValue("LastFM.ApiKey", "ba46f0e84a")).To(Equal("b********a"))
|
||||
Expect(redactValue("Spotify.Secret", "verylongsecret123")).To(Equal("v***************3"))
|
||||
})
|
||||
|
||||
It("fully masks long sensitive values that should be completely hidden", func() {
|
||||
Expect(redactValue("PasswordEncryptionKey", "1234567890")).To(Equal("****"))
|
||||
Expect(redactValue("DevAutoCreateAdminPassword", "1234567890")).To(Equal("****"))
|
||||
Expect(redactValue("Prometheus.Password", "1234567890")).To(Equal("****"))
|
||||
})
|
||||
|
||||
It("fully masks short sensitive values", func() {
|
||||
Expect(redactValue("LastFM.Secret", "short")).To(Equal("****"))
|
||||
Expect(redactValue("Spotify.ID", "abc")).To(Equal("****"))
|
||||
Expect(redactValue("PasswordEncryptionKey", "12345")).To(Equal("****"))
|
||||
Expect(redactValue("DevAutoCreateAdminPassword", "short")).To(Equal("****"))
|
||||
Expect(redactValue("Prometheus.Password", "short")).To(Equal("****"))
|
||||
})
|
||||
|
||||
It("does not mask non-sensitive values", func() {
|
||||
Expect(redactValue("MusicFolder", "/path/to/music")).To(Equal("/path/to/music"))
|
||||
Expect(redactValue("Port", "4533")).To(Equal("4533"))
|
||||
Expect(redactValue("SomeOtherField", "secretvalue")).To(Equal("secretvalue"))
|
||||
})
|
||||
|
||||
It("handles empty values", func() {
|
||||
Expect(redactValue("LastFM.ApiKey", "")).To(Equal(""))
|
||||
Expect(redactValue("NonSensitive", "")).To(Equal(""))
|
||||
})
|
||||
|
||||
It("handles edge case values", func() {
|
||||
Expect(redactValue("LastFM.ApiKey", "a")).To(Equal("****"))
|
||||
Expect(redactValue("LastFM.ApiKey", "ab")).To(Equal("****"))
|
||||
Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g"))
|
||||
})
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
|
||||
func createAuthenticatedConfigRequest(token string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/config/config", nil)
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
func createUnauthenticatedConfigRequest(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
|
||||
}
|
||||
67
server/nativeapi/inspect.go
Normal file
67
server/nativeapi/inspect.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.InspectOutput, error) {
|
||||
file, err := ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if file.Missing {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID)
|
||||
}
|
||||
|
||||
func inspect(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := doInspect(ctx, ds, id)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "could not find file", "id", id)
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error reading tags", "id", id, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
output.MappedTags = nil
|
||||
response, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error marshalling json", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if _, err := w.Write(response); err != nil {
|
||||
log.Error(ctx, "Error sending response to client", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
101
server/nativeapi/library.go
Normal file
101
server/nativeapi/library.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// User-library association endpoints (admin only)
|
||||
func (api *Router) addUserLibraryRoute(r chi.Router) {
|
||||
r.Route("/user/{id}/library", func(r chi.Router) {
|
||||
r.Use(parseUserIDMiddleware)
|
||||
r.Get("/", getUserLibraries(api.libs))
|
||||
r.Put("/", setUserLibraries(api.libs))
|
||||
})
|
||||
}
|
||||
|
||||
// Middleware to parse user ID from URL
|
||||
func parseUserIDMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "id")
|
||||
if userID == "" {
|
||||
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "userID", userID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// User-library association handlers
|
||||
|
||||
func getUserLibraries(service core.Library) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("userID").(string)
|
||||
|
||||
libraries, err := service.GetUserLibraries(r.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Error getting user libraries", "userID", userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(libraries); err != nil {
|
||||
log.Error(r.Context(), "Error encoding user libraries response", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setUserLibraries(service core.Library) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("userID").(string)
|
||||
|
||||
var request struct {
|
||||
LibraryIDs []int `json:"libraryIds"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
log.Error(r.Context(), "Error decoding request", err)
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := service.SetUserLibraries(r.Context(), userID, request.LibraryIDs); err != nil {
|
||||
log.Error(r.Context(), "Error setting user libraries", "userID", userID, err)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, model.ErrValidation) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to set user libraries", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated user libraries
|
||||
libraries, err := service.GetUserLibraries(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error getting updated user libraries", "userID", userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(libraries); err != nil {
|
||||
log.Error(r.Context(), "Error encoding user libraries response", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
424
server/nativeapi/library_test.go
Normal file
424
server/nativeapi/library_test.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"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())
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), 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
|
||||
}
|
||||
94
server/nativeapi/missing.go
Normal file
94
server/nativeapi/missing.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"net/http"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
type missingRepository struct {
|
||||
model.ResourceRepository
|
||||
mfRepo model.MediaFileRepository
|
||||
}
|
||||
|
||||
func newMissingRepository(ds model.DataStore) rest.RepositoryConstructor {
|
||||
return func(ctx context.Context) rest.Repository {
|
||||
return &missingRepository{mfRepo: ds.MediaFile(ctx), ResourceRepository: ds.Resource(ctx, model.MediaFile{})}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *missingRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
opt := r.parseOptions(options)
|
||||
return r.ResourceRepository.Count(opt)
|
||||
}
|
||||
|
||||
func (r *missingRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
opt := r.parseOptions(options)
|
||||
return r.ResourceRepository.ReadAll(opt)
|
||||
}
|
||||
|
||||
func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.QueryOptions {
|
||||
var opt rest.QueryOptions
|
||||
if len(options) > 0 {
|
||||
opt = options[0]
|
||||
opt.Filters = maps.Clone(opt.Filters)
|
||||
}
|
||||
opt.Filters["missing"] = "true"
|
||||
return opt
|
||||
}
|
||||
|
||||
func (r *missingRepository) Read(id string) (any, error) {
|
||||
all, err := r.mfRepo.GetAll(model.QueryOptions{Filters: squirrel.And{
|
||||
squirrel.Eq{"id": id},
|
||||
squirrel.Eq{"missing": true},
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return all[0], nil
|
||||
}
|
||||
|
||||
func (r *missingRepository) EntityName() string {
|
||||
return "missing_files"
|
||||
}
|
||||
|
||||
func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
|
||||
var err error
|
||||
if len(ids) == 0 {
|
||||
err = maintenance.DeleteAllMissingFiles(ctx)
|
||||
} else {
|
||||
err = maintenance.DeleteMissingFiles(ctx, ids)
|
||||
}
|
||||
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Missing file not found", "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "failed to delete missing files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeDeleteManyResponse(w, r, ids)
|
||||
}
|
||||
}
|
||||
|
||||
var _ model.ResourceRepository = &missingRepository{}
|
||||
247
server/nativeapi/native_api.go
Normal file
247
server/nativeapi/native_api.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
share core.Share
|
||||
playlists core.Playlists
|
||||
insights metrics.Insights
|
||||
libs core.Library
|
||||
maintenance core.Maintenance
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
}
|
||||
|
||||
func (api *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Public
|
||||
api.RX(r, "/translation", newTranslationRepository, false)
|
||||
|
||||
// Protected
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.Authenticator(api.ds))
|
||||
r.Use(server.JWTRefresher)
|
||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||
api.R(r, "/user", model.User{}, true)
|
||||
api.R(r, "/song", model.MediaFile{}, false)
|
||||
api.R(r, "/album", model.Album{}, false)
|
||||
api.R(r, "/artist", model.Artist{}, false)
|
||||
api.R(r, "/genre", model.Genre{}, false)
|
||||
api.R(r, "/player", model.Player{}, true)
|
||||
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||
api.R(r, "/radio", model.Radio{}, true)
|
||||
api.R(r, "/tag", model.Tag{}, true)
|
||||
if conf.Server.EnableSharing {
|
||||
api.RX(r, "/share", api.share.NewRepository, true)
|
||||
}
|
||||
|
||||
api.addPlaylistRoute(r)
|
||||
api.addPlaylistTrackRoute(r)
|
||||
api.addSongPlaylistsRoute(r)
|
||||
api.addQueueRoute(r)
|
||||
api.addMissingFilesRoute(r)
|
||||
api.addKeepAliveRoute(r)
|
||||
api.addInsightsRoute(r)
|
||||
|
||||
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
||||
api.addInspectRoute(r)
|
||||
api.addConfigRoute(r)
|
||||
api.addUserLibraryRoute(r)
|
||||
api.RX(r, "/library", api.libs.NewRepository, true)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return api.ds.Resource(ctx, model)
|
||||
}
|
||||
api.RX(r, pathPrefix, constructor, persistable)
|
||||
}
|
||||
|
||||
func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
||||
r.Route(pathPrefix, func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
if persistable {
|
||||
r.Post("/", rest.Post(constructor))
|
||||
}
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", rest.Get(constructor))
|
||||
if persistable {
|
||||
r.Put("/", rest.Put(constructor))
|
||||
r.Delete("/", rest.Delete(constructor))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (api *Router) addPlaylistRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return api.ds.Resource(ctx, model.Playlist{})
|
||||
}
|
||||
|
||||
r.Route("/playlist", func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Content-type") == "application/json" {
|
||||
rest.Post(constructor)(w, r)
|
||||
return
|
||||
}
|
||||
createPlaylistFromM3U(api.playlists)(w, r)
|
||||
})
|
||||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", rest.Get(constructor))
|
||||
r.Put("/", rest.Put(constructor))
|
||||
r.Delete("/", rest.Delete(constructor))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (api *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylist(api.ds)(w, r)
|
||||
})
|
||||
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteFromPlaylist(api.ds)(w, r)
|
||||
})
|
||||
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
addToPlaylist(api.ds)(w, r)
|
||||
})
|
||||
})
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylistTrack(api.ds)(w, r)
|
||||
})
|
||||
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
reorderItem(api.ds)(w, r)
|
||||
})
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteFromPlaylist(api.ds)(w, r)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (api *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
|
||||
getSongPlaylists(api.ds)(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *Router) addQueueRoute(r chi.Router) {
|
||||
r.Route("/queue", func(r chi.Router) {
|
||||
r.Get("/", getQueue(api.ds))
|
||||
r.Post("/", saveQueue(api.ds))
|
||||
r.Put("/", updateQueue(api.ds))
|
||||
r.Delete("/", clearQueue(api.ds))
|
||||
})
|
||||
}
|
||||
|
||||
func (api *Router) addMissingFilesRoute(r chi.Router) {
|
||||
r.Route("/missing", func(r chi.Router) {
|
||||
api.RX(r, "/", newMissingRepository(api.ds), false)
|
||||
r.Delete("/", deleteMissingFiles(api.maintenance))
|
||||
})
|
||||
}
|
||||
|
||||
func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []string) {
|
||||
var resp []byte
|
||||
var err error
|
||||
if len(ids) == 1 {
|
||||
resp = []byte(`{"id":"` + html.EscapeString(ids[0]) + `"}`)
|
||||
} else {
|
||||
resp, err = json.Marshal(&struct {
|
||||
Ids []string `json:"ids"`
|
||||
}{Ids: ids})
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error marshaling response", "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
_, err = w.Write(resp)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *Router) addInspectRoute(r chi.Router) {
|
||||
if conf.Server.Inspect.Enabled {
|
||||
r.Group(func(r chi.Router) {
|
||||
if conf.Server.Inspect.MaxRequests > 0 {
|
||||
log.Debug("Throttling inspect", "maxRequests", conf.Server.Inspect.MaxRequests,
|
||||
"backlogLimit", conf.Server.Inspect.BacklogLimit, "backlogTimeout",
|
||||
conf.Server.Inspect.BacklogTimeout)
|
||||
r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout)))
|
||||
}
|
||||
r.Get("/inspect", inspect(api.ds))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (api *Router) addConfigRoute(r chi.Router) {
|
||||
if conf.Server.DevUIShowConfig {
|
||||
r.Get("/config/*", getConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *Router) addKeepAliveRoute(r chi.Router) {
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
||||
})
|
||||
}
|
||||
|
||||
func (api *Router) addInsightsRoute(r chi.Router) {
|
||||
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
last, success := api.insights.LastRun(r.Context())
|
||||
if conf.Server.EnableInsightsCollector {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Middleware to ensure only admin users can access endpoints
|
||||
func adminOnlyMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := request.UserFrom(r.Context())
|
||||
if !ok || !user.IsAdmin {
|
||||
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
431
server/nativeapi/native_api_song_test.go
Normal file
431
server/nativeapi/native_api_song_test.go
Normal file
@@ -0,0 +1,431 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"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("Song Endpoints", func() {
|
||||
var (
|
||||
router http.Handler
|
||||
ds *tests.MockDataStore
|
||||
mfRepo *tests.MockMediaFileRepo
|
||||
userRepo *tests.MockedUserRepo
|
||||
w *httptest.ResponseRecorder
|
||||
testUser model.User
|
||||
testSongs model.MediaFiles
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
|
||||
// Setup mock repositories
|
||||
mfRepo = tests.CreateMockMediaFileRepo()
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedMediaFile: mfRepo,
|
||||
MockedUser: userRepo,
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
}
|
||||
|
||||
// Initialize auth system
|
||||
auth.Init(ds)
|
||||
|
||||
// Create test user
|
||||
testUser = model.User{
|
||||
ID: "user-1",
|
||||
UserName: "testuser",
|
||||
Name: "Test User",
|
||||
IsAdmin: false,
|
||||
NewPassword: "testpass",
|
||||
}
|
||||
err := userRepo.Put(&testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create test songs
|
||||
testSongs = model.MediaFiles{
|
||||
{
|
||||
ID: "song-1",
|
||||
Title: "Test Song 1",
|
||||
Artist: "Test Artist 1",
|
||||
Album: "Test Album 1",
|
||||
AlbumID: "album-1",
|
||||
ArtistID: "artist-1",
|
||||
Duration: 180.5,
|
||||
BitRate: 320,
|
||||
Path: "/music/song1.mp3",
|
||||
Suffix: "mp3",
|
||||
Size: 5242880,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: "song-2",
|
||||
Title: "Test Song 2",
|
||||
Artist: "Test Artist 2",
|
||||
Album: "Test Album 2",
|
||||
AlbumID: "album-2",
|
||||
ArtistID: "artist-2",
|
||||
Duration: 240.0,
|
||||
BitRate: 256,
|
||||
Path: "/music/song2.mp3",
|
||||
Suffix: "mp3",
|
||||
Size: 7340032,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
mfRepo.SetData(testSongs)
|
||||
|
||||
// Create the native API router and wrap it with the JWTVerifier middleware
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
// Helper function to create unauthenticated request
|
||||
createUnauthenticatedRequest := func(method, path string, body []byte) *http.Request {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// Helper function to create authenticated request with JWT token
|
||||
createAuthenticatedRequest := func(method, path string, body []byte) *http.Request {
|
||||
req := createUnauthenticatedRequest(method, path, body)
|
||||
|
||||
// Create JWT token for the test user
|
||||
token, err := auth.CreateToken(&testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Add JWT token to Authorization header
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
Describe("GET /song", func() {
|
||||
Context("when user is authenticated", func() {
|
||||
It("returns all songs", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(response).To(HaveLen(2))
|
||||
Expect(response[0].ID).To(Equal("song-1"))
|
||||
Expect(response[0].Title).To(Equal("Test Song 1"))
|
||||
Expect(response[1].ID).To(Equal("song-2"))
|
||||
Expect(response[1].Title).To(Equal("Test Song 2"))
|
||||
})
|
||||
|
||||
It("handles repository errors gracefully", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when user is not authenticated", func() {
|
||||
It("returns unauthorized", func() {
|
||||
req := createUnauthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GET /song/{id}", func() {
|
||||
Context("when user is authenticated", func() {
|
||||
It("returns the specific song", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song/song-1", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response model.MediaFile
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(response.ID).To(Equal("song-1"))
|
||||
Expect(response.Title).To(Equal("Test Song 1"))
|
||||
Expect(response.Artist).To(Equal("Test Artist 1"))
|
||||
})
|
||||
|
||||
It("returns 404 for non-existent song", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song/non-existent", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("handles repository errors gracefully", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/song/song-1", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when user is not authenticated", func() {
|
||||
It("returns unauthorized", func() {
|
||||
req := createUnauthenticatedRequest("GET", "/song/song-1", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Song endpoints are read-only", func() {
|
||||
Context("POST /song", func() {
|
||||
It("should not be available (songs are not persistable)", func() {
|
||||
newSong := model.MediaFile{
|
||||
Title: "New Song",
|
||||
Artist: "New Artist",
|
||||
Album: "New Album",
|
||||
Duration: 200.0,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(newSong)
|
||||
req := createAuthenticatedRequest("POST", "/song", body)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should return 405 Method Not Allowed or 404 Not Found
|
||||
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
||||
})
|
||||
})
|
||||
|
||||
Context("PUT /song/{id}", func() {
|
||||
It("should not be available (songs are not persistable)", func() {
|
||||
updatedSong := model.MediaFile{
|
||||
ID: "song-1",
|
||||
Title: "Updated Song",
|
||||
Artist: "Updated Artist",
|
||||
Album: "Updated Album",
|
||||
Duration: 250.0,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(updatedSong)
|
||||
req := createAuthenticatedRequest("PUT", "/song/song-1", body)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should return 405 Method Not Allowed or 404 Not Found
|
||||
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
||||
})
|
||||
})
|
||||
|
||||
Context("DELETE /song/{id}", func() {
|
||||
It("should not be available (songs are not persistable)", func() {
|
||||
req := createAuthenticatedRequest("DELETE", "/song/song-1", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should return 405 Method Not Allowed or 404 Not Found
|
||||
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Query parameters and filtering", func() {
|
||||
Context("when using query parameters", func() {
|
||||
It("handles pagination parameters", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song?_start=0&_end=1", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Should still return all songs since our mock doesn't implement pagination
|
||||
// but the request should be processed successfully
|
||||
Expect(len(response)).To(BeNumerically(">=", 1))
|
||||
})
|
||||
|
||||
It("handles sort parameters", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song?_sort=title&_order=ASC", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(response).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("handles filter parameters", func() {
|
||||
// Properly encode the URL with query parameters
|
||||
baseURL := "/song"
|
||||
params := url.Values{}
|
||||
params.Add("title", "Test Song 1")
|
||||
fullURL := baseURL + "?" + params.Encode()
|
||||
|
||||
req := createAuthenticatedRequest("GET", fullURL, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Mock doesn't implement filtering, but request should be processed
|
||||
Expect(len(response)).To(BeNumerically(">=", 1))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Response headers and content type", func() {
|
||||
It("sets correct content type for JSON responses", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json"))
|
||||
})
|
||||
|
||||
It("includes total count header when available", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
// The X-Total-Count header might be set by the REST framework
|
||||
// We just verify the request is processed successfully
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Edge cases and error handling", func() {
|
||||
Context("when repository is unavailable", func() {
|
||||
It("handles database connection errors", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no songs exist", func() {
|
||||
It("returns empty array when no songs are found", func() {
|
||||
mfRepo.SetData(model.MediaFiles{}) // Empty dataset
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(response).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Authentication middleware integration", func() {
|
||||
Context("with different user types", func() {
|
||||
It("works with admin users", func() {
|
||||
adminUser := model.User{
|
||||
ID: "admin-1",
|
||||
UserName: "admin",
|
||||
Name: "Admin User",
|
||||
IsAdmin: true,
|
||||
NewPassword: "adminpass",
|
||||
}
|
||||
err := userRepo.Put(&adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create JWT token for admin user
|
||||
token, err := auth.CreateToken(&adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := createUnauthenticatedRequest("GET", "/song", nil)
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("works with regular users", func() {
|
||||
regularUser := model.User{
|
||||
ID: "user-2",
|
||||
UserName: "regular",
|
||||
Name: "Regular User",
|
||||
IsAdmin: false,
|
||||
NewPassword: "userpass",
|
||||
}
|
||||
err := userRepo.Put(®ularUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create JWT token for regular user
|
||||
token, err := auth.CreateToken(®ularUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := createUnauthenticatedRequest("GET", "/song", nil)
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with missing authentication context", func() {
|
||||
It("rejects requests without user context", func() {
|
||||
req := createUnauthenticatedRequest("GET", "/song", nil)
|
||||
// No authentication header added
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("rejects requests with invalid JWT tokens", func() {
|
||||
req := createUnauthenticatedRequest("GET", "/song", nil)
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer invalid.token.here")
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
17
server/nativeapi/native_api_suite_test.go
Normal file
17
server/nativeapi/native_api_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestNativeApi(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Native RESTful API Suite")
|
||||
}
|
||||
244
server/nativeapi/playlists.go
Normal file
244
server/nativeapi/playlists.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
p := req.Params(r)
|
||||
start := p.Int64Or("_start", 0)
|
||||
return plsRepo.Tracks(plsId, start == 0)
|
||||
}
|
||||
|
||||
handler(constructor).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
accept := r.Header.Get("accept")
|
||||
if strings.ToLower(accept) == "audio/x-mpegurl" {
|
||||
handleExportPlaylist(ds)(w, r)
|
||||
return
|
||||
}
|
||||
wrapper(rest.GetAll)(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func getPlaylistTrack(ds model.DataStore) http.HandlerFunc {
|
||||
// Add a middleware to capture the playlistId
|
||||
wrapper := func(handler restHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
return plsRepo.Tracks(plsId, true)
|
||||
}
|
||||
|
||||
handler(constructor).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
return wrapper(rest.Get)
|
||||
}
|
||||
|
||||
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
pls, err := playlists.ImportM3U(ctx, r.Body)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error parsing playlist", err)
|
||||
// TODO: consider returning StatusBadRequest for playlists that are malformed
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, err = w.Write([]byte(pls.ToM3U8()))
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending m3u contents", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleExportPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
pls, err := plsRepo.GetWithTracks(plsId, true, false)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(r.Context(), "Playlist not found", "playlistId", plsId)
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error retrieving the playlist", "playlistId", plsId, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", pls.Name)
|
||||
w.Header().Set("Content-Type", "audio/x-mpegurl")
|
||||
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name)
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
|
||||
_, err = w.Write([]byte(pls.ToM3U8()))
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending playlist", "name", pls.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
playlistId, _ := p.String(":playlistId")
|
||||
ids, _ := p.Strings("id")
|
||||
err := ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
tracksRepo := tx.Playlist(r.Context()).Tracks(playlistId, true)
|
||||
return tracksRepo.Delete(ids...)
|
||||
})
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error deleting tracks from playlist", "playlistId", playlistId, "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeDeleteManyResponse(w, r, ids)
|
||||
}
|
||||
}
|
||||
|
||||
func addToPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
type addTracksPayload struct {
|
||||
Ids []string `json:"ids"`
|
||||
AlbumIds []string `json:"albumIds"`
|
||||
ArtistIds []string `json:"artistIds"`
|
||||
Discs []model.DiscID `json:"discs"`
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
playlistId, _ := p.String(":playlistId")
|
||||
var payload addTracksPayload
|
||||
err := json.NewDecoder(r.Body).Decode(&payload)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true)
|
||||
count, c := 0, 0
|
||||
if c, err = tracksRepo.Add(payload.Ids); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
count += c
|
||||
if c, err = tracksRepo.AddAlbums(payload.AlbumIds); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
count += c
|
||||
if c, err = tracksRepo.AddArtists(payload.ArtistIds); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
count += c
|
||||
if c, err = tracksRepo.AddDiscs(payload.Discs); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
count += c
|
||||
|
||||
// Must return an object with an ID, to satisfy ReactAdmin `create` call
|
||||
_, err = fmt.Fprintf(w, `{"added":%d}`, count)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reorderItem(ds model.DataStore) http.HandlerFunc {
|
||||
type reorderPayload struct {
|
||||
InsertBefore string `json:"insert_before"`
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
playlistId, _ := p.String(":playlistId")
|
||||
id := p.IntOr(":id", 0)
|
||||
if id == 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var payload reorderPayload
|
||||
err := json.NewDecoder(r.Body).Decode(&payload)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
newPos, err := strconv.Atoi(payload.InsertBefore)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true)
|
||||
err = tracksRepo.Reorder(id, newPos)
|
||||
if errors.Is(err, rest.ErrPermissionDenied) {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = w.Write([]byte(fmt.Sprintf(`{"id":"%d"}`, id)))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
trackId, _ := p.String(":id")
|
||||
playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(playlists)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
}
|
||||
214
server/nativeapi/queue.go
Normal file
214
server/nativeapi/queue.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type updateQueuePayload struct {
|
||||
Ids *[]string `json:"ids,omitempty"`
|
||||
Current *int `json:"current,omitempty"`
|
||||
Position *int64 `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
// validateCurrentIndex validates that the current index is within bounds of the items array.
|
||||
// Returns false if validation fails (and sends error response), true if validation passes.
|
||||
func validateCurrentIndex(w http.ResponseWriter, current int, itemsLength int) bool {
|
||||
if current < 0 || current >= itemsLength {
|
||||
http.Error(w, "current index out of bounds", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// retrieveExistingQueue retrieves an existing play queue for a user with proper error handling.
|
||||
// Returns the queue (nil if not found) and false if an error occurred and response was sent.
|
||||
func retrieveExistingQueue(ctx context.Context, w http.ResponseWriter, ds model.DataStore, userID string) (*model.PlayQueue, bool) {
|
||||
existing, err := ds.PlayQueue(ctx).Retrieve(userID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, "Error retrieving queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return nil, false
|
||||
}
|
||||
return existing, true
|
||||
}
|
||||
|
||||
// decodeUpdatePayload decodes the JSON payload from the request body.
|
||||
// Returns false if decoding fails (and sends error response), true if successful.
|
||||
func decodeUpdatePayload(w http.ResponseWriter, r *http.Request) (*updateQueuePayload, bool) {
|
||||
var payload updateQueuePayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return nil, false
|
||||
}
|
||||
return &payload, true
|
||||
}
|
||||
|
||||
// createMediaFileItems converts a slice of IDs to MediaFile items.
|
||||
func createMediaFileItems(ids []string) []model.MediaFile {
|
||||
return slice.Map(ids, func(id string) model.MediaFile {
|
||||
return model.MediaFile{ID: id}
|
||||
})
|
||||
}
|
||||
|
||||
// extractUserAndClient extracts user and client from the request context.
|
||||
func extractUserAndClient(ctx context.Context) (model.User, string) {
|
||||
user, _ := request.UserFrom(ctx)
|
||||
client, _ := request.ClientFrom(ctx)
|
||||
return user, client
|
||||
}
|
||||
|
||||
func getQueue(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user, _ := request.UserFrom(ctx)
|
||||
repo := ds.PlayQueue(ctx)
|
||||
pq, err := repo.RetrieveWithMediaFiles(user.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, "Error retrieving queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if pq == nil {
|
||||
pq = &model.PlayQueue{}
|
||||
}
|
||||
resp, err := json.Marshal(pq)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error marshalling queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(resp)
|
||||
}
|
||||
}
|
||||
|
||||
func saveQueue(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
payload, ok := decodeUpdatePayload(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, client := extractUserAndClient(ctx)
|
||||
ids := V(payload.Ids)
|
||||
items := createMediaFileItems(ids)
|
||||
current := V(payload.Current)
|
||||
if len(ids) > 0 && !validateCurrentIndex(w, current, len(ids)) {
|
||||
return
|
||||
}
|
||||
pq := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: current,
|
||||
Position: max(V(payload.Position), 0),
|
||||
ChangedBy: client,
|
||||
Items: items,
|
||||
}
|
||||
if err := ds.PlayQueue(ctx).Store(pq); err != nil {
|
||||
log.Error(ctx, "Error saving queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func updateQueue(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Decode and validate the JSON payload
|
||||
payload, ok := decodeUpdatePayload(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user and client information from request context
|
||||
user, client := extractUserAndClient(ctx)
|
||||
|
||||
// Initialize play queue with user ID and client info
|
||||
pq := &model.PlayQueue{UserID: user.ID, ChangedBy: client}
|
||||
var cols []string // Track which columns to update in the database
|
||||
|
||||
// Handle queue items update
|
||||
if payload.Ids != nil {
|
||||
pq.Items = createMediaFileItems(*payload.Ids)
|
||||
cols = append(cols, "items")
|
||||
|
||||
// If current index is not being updated, validate existing current index
|
||||
// against the new items list to ensure it remains valid
|
||||
if payload.Current == nil {
|
||||
existing, ok := retrieveExistingQueue(ctx, w, ds, user.ID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if existing != nil && !validateCurrentIndex(w, existing.Current, len(*payload.Ids)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle current track index update
|
||||
if payload.Current != nil {
|
||||
pq.Current = *payload.Current
|
||||
cols = append(cols, "current")
|
||||
|
||||
if payload.Ids != nil {
|
||||
// If items are also being updated, validate current index against new items
|
||||
if !validateCurrentIndex(w, *payload.Current, len(*payload.Ids)) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// If only current index is being updated, validate against existing items
|
||||
existing, ok := retrieveExistingQueue(ctx, w, ds, user.ID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if existing != nil && !validateCurrentIndex(w, *payload.Current, len(existing.Items)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle playback position update
|
||||
if payload.Position != nil {
|
||||
pq.Position = max(*payload.Position, 0) // Ensure position is non-negative
|
||||
cols = append(cols, "position")
|
||||
}
|
||||
|
||||
// If no fields were specified for update, return success without doing anything
|
||||
if len(cols) == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Perform partial update of the specified columns only
|
||||
if err := ds.PlayQueue(ctx).Store(pq, cols...); err != nil {
|
||||
log.Error(ctx, "Error updating queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func clearQueue(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user, _ := request.UserFrom(ctx)
|
||||
if err := ds.PlayQueue(ctx).Clear(user.ID); err != nil {
|
||||
log.Error(ctx, "Error clearing queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
282
server/nativeapi/queue_test.go
Normal file
282
server/nativeapi/queue_test.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Queue Endpoints", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
repo *tests.MockPlayQueueRepo
|
||||
user model.User
|
||||
userRepo *tests.MockedUserRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = &tests.MockPlayQueueRepo{}
|
||||
user = model.User{ID: "u1", UserName: "user"}
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
_ = userRepo.Put(&user)
|
||||
ds = &tests.MockDataStore{MockedPlayQueue: repo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}}
|
||||
})
|
||||
|
||||
Describe("POST /queue", func() {
|
||||
It("saves the queue", func() {
|
||||
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1), Position: gg.P(int64(10))}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
ctx := request.WithUser(req.Context(), user)
|
||||
ctx = request.WithClient(ctx, "TestClient")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
Expect(repo.Queue).ToNot(BeNil())
|
||||
Expect(repo.Queue.Current).To(Equal(1))
|
||||
Expect(repo.Queue.Items).To(HaveLen(2))
|
||||
Expect(repo.Queue.Items[1].ID).To(Equal("s2"))
|
||||
Expect(repo.Queue.ChangedBy).To(Equal("TestClient"))
|
||||
})
|
||||
|
||||
It("saves an empty queue", func() {
|
||||
payload := updateQueuePayload{Ids: gg.P([]string{}), Current: gg.P(0), Position: gg.P(int64(0))}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
Expect(repo.Queue).ToNot(BeNil())
|
||||
Expect(repo.Queue.Items).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("returns bad request for invalid current index (negative)", func() {
|
||||
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(-1), Position: gg.P(int64(10))}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(w.Body.String()).To(ContainSubstring("current index out of bounds"))
|
||||
})
|
||||
|
||||
It("returns bad request for invalid current index (too large)", func() {
|
||||
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(2), Position: gg.P(int64(10))}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(w.Body.String()).To(ContainSubstring("current index out of bounds"))
|
||||
})
|
||||
|
||||
It("returns bad request for malformed JSON", func() {
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader([]byte("invalid json")))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns internal server error when store fails", func() {
|
||||
repo.Err = true
|
||||
payload := updateQueuePayload{Ids: gg.P([]string{"s1"}), Current: gg.P(0), Position: gg.P(int64(10))}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GET /queue", func() {
|
||||
It("returns the queue", func() {
|
||||
queue := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: 1,
|
||||
Position: 55,
|
||||
Items: model.MediaFiles{
|
||||
{ID: "track1", Title: "Song 1"},
|
||||
{ID: "track2", Title: "Song 2"},
|
||||
{ID: "track3", Title: "Song 3"},
|
||||
},
|
||||
}
|
||||
repo.Queue = queue
|
||||
req := httptest.NewRequest("GET", "/queue", nil)
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
getQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
var resp model.PlayQueue
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
Expect(resp.Current).To(Equal(1))
|
||||
Expect(resp.Position).To(Equal(int64(55)))
|
||||
Expect(resp.Items).To(HaveLen(3))
|
||||
Expect(resp.Items[0].ID).To(Equal("track1"))
|
||||
Expect(resp.Items[1].ID).To(Equal("track2"))
|
||||
Expect(resp.Items[2].ID).To(Equal("track3"))
|
||||
})
|
||||
|
||||
It("returns empty queue when user has no queue", func() {
|
||||
req := httptest.NewRequest("GET", "/queue", nil)
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
getQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp model.PlayQueue
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
Expect(resp.Items).To(BeEmpty())
|
||||
Expect(resp.Current).To(Equal(0))
|
||||
Expect(resp.Position).To(Equal(int64(0)))
|
||||
})
|
||||
|
||||
It("returns internal server error when retrieve fails", func() {
|
||||
repo.Err = true
|
||||
req := httptest.NewRequest("GET", "/queue", nil)
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
getQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PUT /queue", func() {
|
||||
It("updates the queue fields", func() {
|
||||
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}}}
|
||||
payload := updateQueuePayload{Current: gg.P(2), Position: gg.P(int64(20))}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
|
||||
ctx := request.WithUser(req.Context(), user)
|
||||
ctx = request.WithClient(ctx, "TestClient")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
updateQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
Expect(repo.Queue).ToNot(BeNil())
|
||||
Expect(repo.Queue.Current).To(Equal(2))
|
||||
Expect(repo.Queue.Position).To(Equal(int64(20)))
|
||||
Expect(repo.Queue.ChangedBy).To(Equal("TestClient"))
|
||||
})
|
||||
|
||||
It("updates only ids", func() {
|
||||
repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 1}
|
||||
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
updateQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
Expect(repo.Queue.Items).To(HaveLen(2))
|
||||
Expect(repo.LastCols).To(ConsistOf("items"))
|
||||
})
|
||||
|
||||
It("updates ids and current", func() {
|
||||
repo.Queue = &model.PlayQueue{UserID: user.ID}
|
||||
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1)}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
updateQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
Expect(repo.Queue.Items).To(HaveLen(2))
|
||||
Expect(repo.Queue.Current).To(Equal(1))
|
||||
Expect(repo.LastCols).To(ConsistOf("items", "current"))
|
||||
})
|
||||
|
||||
It("returns bad request when new ids invalidate current", func() {
|
||||
repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 2}
|
||||
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
updateQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns bad request when current out of bounds", func() {
|
||||
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}}
|
||||
payload := updateQueuePayload{Current: gg.P(3)}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
updateQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns bad request for malformed JSON", func() {
|
||||
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader([]byte("{")))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
updateQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns internal server error when store fails", func() {
|
||||
repo.Err = true
|
||||
payload := updateQueuePayload{Position: gg.P(int64(10))}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
updateQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DELETE /queue", func() {
|
||||
It("clears the queue", func() {
|
||||
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}}
|
||||
req := httptest.NewRequest("DELETE", "/queue", nil)
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
clearQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
Expect(repo.Queue).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns internal server error when clear fails", func() {
|
||||
repo.Err = true
|
||||
req := httptest.NewRequest("DELETE", "/queue", nil)
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
clearQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
})
|
||||
123
server/nativeapi/translations.go
Normal file
123
server/nativeapi/translations.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
)
|
||||
|
||||
type translation struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
func newTranslationRepository(context.Context) rest.Repository {
|
||||
return &translationRepository{}
|
||||
}
|
||||
|
||||
type translationRepository struct{}
|
||||
|
||||
func (r *translationRepository) Read(id string) (interface{}, error) {
|
||||
translations, _ := loadTranslations()
|
||||
if t, ok := translations[id]; ok {
|
||||
return t, nil
|
||||
}
|
||||
return nil, rest.ErrNotFound
|
||||
}
|
||||
|
||||
// Count simple implementation, does not support any `options`
|
||||
func (r *translationRepository) Count(...rest.QueryOptions) (int64, error) {
|
||||
_, count := loadTranslations()
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ReadAll simple implementation, only returns IDs. Does not support any `options`
|
||||
func (r *translationRepository) ReadAll(...rest.QueryOptions) (interface{}, error) {
|
||||
translations, _ := loadTranslations()
|
||||
var result []translation
|
||||
for _, t := range translations {
|
||||
t.Data = ""
|
||||
result = append(result, t)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *translationRepository) EntityName() string {
|
||||
return "translation"
|
||||
}
|
||||
|
||||
func (r *translationRepository) NewInstance() interface{} {
|
||||
return &translation{}
|
||||
}
|
||||
|
||||
var loadTranslations = sync.OnceValues(func() (map[string]translation, int64) {
|
||||
translations := make(map[string]translation)
|
||||
fsys := resources.FS()
|
||||
dir, err := fsys.Open(consts.I18nFolder)
|
||||
if err != nil {
|
||||
log.Error("Error opening translation folder", err)
|
||||
return translations, 0
|
||||
}
|
||||
files, err := dir.(fs.ReadDirFile).ReadDir(-1)
|
||||
if err != nil {
|
||||
log.Error("Error reading translation folder", err)
|
||||
return translations, 0
|
||||
}
|
||||
var languages []string
|
||||
for _, f := range files {
|
||||
t, err := loadTranslation(fsys, f.Name())
|
||||
if err != nil {
|
||||
log.Error("Error loading translation file", "file", f.Name(), err)
|
||||
continue
|
||||
}
|
||||
translations[t.ID] = t
|
||||
languages = append(languages, t.ID)
|
||||
}
|
||||
log.Info("Loaded translations", "languages", languages)
|
||||
return translations, int64(len(translations))
|
||||
})
|
||||
|
||||
func loadTranslation(fsys fs.FS, fileName string) (translation translation, err error) {
|
||||
// Get id and full path
|
||||
name := path.Base(fileName)
|
||||
id := strings.TrimSuffix(name, path.Ext(name))
|
||||
filePath := path.Join(consts.I18nFolder, name)
|
||||
|
||||
// Load translation from json file
|
||||
file, err := fsys.Open(filePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err = json.Unmarshal(data, &out); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Compress JSON
|
||||
buf := new(bytes.Buffer)
|
||||
if err = json.Compact(buf, data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
translation.Data = buf.String()
|
||||
translation.Name = out["languageName"].(string)
|
||||
translation.ID = id
|
||||
return
|
||||
}
|
||||
|
||||
var _ rest.Repository = (*translationRepository)(nil)
|
||||
47
server/nativeapi/translations_test.go
Normal file
47
server/nativeapi/translations_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Translations", func() {
|
||||
Describe("I18n files", func() {
|
||||
It("contains only valid json language files", func() {
|
||||
fsys := resources.FS()
|
||||
dir, _ := fsys.Open(consts.I18nFolder)
|
||||
files, _ := dir.(fs.ReadDirFile).ReadDir(-1)
|
||||
for _, f := range files {
|
||||
name := filepath.Base(f.Name())
|
||||
filePath := filepath.Join(consts.I18nFolder, name)
|
||||
file, _ := fsys.Open(filePath)
|
||||
data, _ := io.ReadAll(file)
|
||||
var out map[string]interface{}
|
||||
|
||||
Expect(filepath.Ext(filePath)).To(Equal(".json"), filePath)
|
||||
Expect(json.Unmarshal(data, &out)).To(BeNil(), filePath)
|
||||
Expect(out["languageName"]).ToNot(BeEmpty(), filePath)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("loadTranslation", func() {
|
||||
It("loads a translation file correctly", func() {
|
||||
fs := os.DirFS("ui/src")
|
||||
tr, err := loadTranslation(fs, "en.json")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(tr.ID).To(Equal("en"))
|
||||
Expect(tr.Name).To(Equal("English"))
|
||||
var out map[string]interface{}
|
||||
Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user