navidrome/server/nativeapi/library.go
Deluan 7dce0da557 feat(api): restrict access to library and config endpoints to admin users
Signed-off-by: Deluan <deluan@navidrome.org>
2025-07-16 18:10:14 -04:00

322 lines
9.8 KiB
Go

package nativeapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/Masterminds/squirrel"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// Library management endpoints (admin only)
func (n *Router) addLibraryRoute(r chi.Router) {
r.Route("/library", func(r chi.Router) {
r.Get("/", getLibraries(n.ds))
r.Post("/", createLibrary(n.ds))
r.Route("/{id}", func(r chi.Router) {
r.Use(parseIDMiddleware)
r.Get("/", getLibrary(n.ds))
r.Put("/", updateLibrary(n.ds))
r.Delete("/", deleteLibrary(n.ds))
})
})
}
// User-library association endpoints (admin only)
func (n *Router) addUserLibraryRoute(r chi.Router) {
r.Route("/user/{id}/library", func(r chi.Router) {
r.Use(parseUserIDMiddleware)
r.Get("/", getUserLibraries(n.ds))
r.Put("/", setUserLibraries(n.ds))
})
}
// Middleware to parse library ID from URL
func parseIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid library ID", http.StatusBadRequest)
return
}
ctx := context.WithValue(r.Context(), "libraryID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 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))
})
}
// Library CRUD handlers
func getLibraries(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
libraries, err := ds.Library(r.Context()).GetAll()
if err != nil {
log.Error(r.Context(), "Error getting libraries", 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 libraries response", err)
}
}
}
func getLibrary(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
libraryID := r.Context().Value("libraryID").(int)
library, err := ds.Library(r.Context()).Get(libraryID)
if err != nil {
if err == model.ErrNotFound {
http.Error(w, "Library not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error getting library", "libraryID", libraryID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(library); err != nil {
log.Error(r.Context(), "Error encoding library response", err)
}
}
}
func createLibrary(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var library model.Library
if err := json.NewDecoder(r.Body).Decode(&library); err != nil {
log.Error(r.Context(), "Error decoding library request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate required fields
if library.Name == "" {
http.Error(w, "Library name is required", http.StatusBadRequest)
return
}
if library.Path == "" {
http.Error(w, "Library path is required", http.StatusBadRequest)
return
}
if err := ds.Library(r.Context()).Put(&library); err != nil {
log.Error(r.Context(), "Error creating library", err)
http.Error(w, "Failed to create library", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(library); err != nil {
log.Error(r.Context(), "Error encoding library response", err)
}
}
}
func updateLibrary(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
libraryID := r.Context().Value("libraryID").(int)
var library model.Library
if err := json.NewDecoder(r.Body).Decode(&library); err != nil {
log.Error(r.Context(), "Error decoding library request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Ensure the ID matches the URL parameter
library.ID = libraryID
// Validate required fields
if library.Name == "" {
http.Error(w, "Library name is required", http.StatusBadRequest)
return
}
if library.Path == "" {
http.Error(w, "Library path is required", http.StatusBadRequest)
return
}
if err := ds.Library(r.Context()).Put(&library); err != nil {
if err == model.ErrNotFound {
http.Error(w, "Library not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error updating library", "libraryID", libraryID, err)
http.Error(w, "Failed to update library", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(library); err != nil {
log.Error(r.Context(), "Error encoding library response", err)
}
}
}
func deleteLibrary(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
libraryID := r.Context().Value("libraryID").(int)
if err := ds.Library(r.Context()).Delete(libraryID); err != nil {
if err == model.ErrNotFound {
http.Error(w, "Library not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error deleting library", "libraryID", libraryID, err)
http.Error(w, "Failed to delete library", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// User-library association handlers
func getUserLibraries(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
// Verify user exists
if _, err := ds.User(r.Context()).Get(userID); err != nil {
if err == model.ErrNotFound {
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error getting user", "userID", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
libraries, err := ds.User(r.Context()).GetUserLibraries(userID)
if err != nil {
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(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
// Verify user exists
user, err := ds.User(r.Context()).Get(userID)
if err != nil {
if err == model.ErrNotFound {
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error getting user", "userID", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
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
}
// Admin users get all libraries automatically - don't allow manual assignment
if user.IsAdmin {
http.Error(w, "Cannot manually assign libraries to admin users", http.StatusBadRequest)
return
}
// Validate library IDs exist
if len(request.LibraryIDs) > 0 {
// Use CountAll with IN filter to efficiently check if all library IDs exist
count, err := ds.Library(r.Context()).CountAll(model.QueryOptions{
Filters: squirrel.Eq{"id": request.LibraryIDs},
})
if err != nil {
log.Error(r.Context(), "Error counting libraries", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if int(count) != len(request.LibraryIDs) {
// Some library IDs don't exist - find which ones for better error message
existingLibraries, err := ds.Library(r.Context()).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"id": request.LibraryIDs},
})
if err != nil {
log.Error(r.Context(), "Error getting all libraries", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
libraryMap := make(map[int]bool)
for _, lib := range existingLibraries {
libraryMap[lib.ID] = true
}
for _, libID := range request.LibraryIDs {
if !libraryMap[libID] {
http.Error(w, fmt.Sprintf("Library ID %d does not exist", libID), http.StatusBadRequest)
return
}
}
}
}
// Regular users must have at least one library
if len(request.LibraryIDs) == 0 {
http.Error(w, "At least one library must be assigned to non-admin users", http.StatusBadRequest)
return
}
if err := ds.User(r.Context()).SetUserLibraries(userID, request.LibraryIDs); err != nil {
log.Error(r.Context(), "Error setting user libraries", "userID", userID, err)
http.Error(w, "Failed to set user libraries", http.StatusInternalServerError)
return
}
// Return updated user libraries
libraries, err := ds.User(r.Context()).GetUserLibraries(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)
}
}
}