0c5c10d00b
Bug: r.Route("/api/admin", ...) in server.go for the /scan endpoint
created a second chi subtree at the same prefix as the nested admin
Route inside api.Mount. chi.Walk shows both subtrees registered, but
runtime routing dispatch only matched one — so every /api/admin/*
endpoint registered by api.Mount (Lidarr config, quarantine admin,
requests admin) silently returned 404 to authenticated callers.
Has shipped this way since 06a1fe1; M5a/b/c admin tests didn't catch
it because they call api.Mount on a fresh chi.NewRouter() rather than
going through Server.Router(), so only one /api/admin registration
existed in those tests.
Fix: register /api/admin/scan as a single inline-middleware route
(r.With(...).Post(...)) instead of a Route subtree, eliminating the
duplicate /api/admin mount.
Adds a regression test that uses a real seeded admin session to make
an authenticated GET /api/admin/lidarr/config and asserts != 404.
Verified the test fails without the fix and passes with it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
123 lines
4.5 KiB
Go
123 lines
4.5 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"time"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/api"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
|
"git.fabledsword.com/bvandeusen/minstrel/web"
|
|
)
|
|
|
|
// ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an
|
|
// interface so tests can stub it without touching the DB.
|
|
type ScanTrigger interface {
|
|
Scan(ctx context.Context) (library.Stats, error)
|
|
}
|
|
|
|
type Server struct {
|
|
Logger *slog.Logger
|
|
Pool *pgxpool.Pool
|
|
Scanner ScanTrigger
|
|
SubsonicCfg subsonic.Config
|
|
EventsCfg config.EventsConfig
|
|
RecommendationCfg config.RecommendationConfig
|
|
}
|
|
|
|
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig) *Server {
|
|
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg}
|
|
}
|
|
|
|
func (s *Server) Router() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.RequestID)
|
|
r.Use(middleware.Recoverer)
|
|
|
|
r.Get("/healthz", s.handleHealthz)
|
|
|
|
if s.Pool != nil {
|
|
writer := playevents.NewWriter(
|
|
s.Pool, s.Logger,
|
|
time.Duration(s.EventsCfg.SessionTimeoutMinutes)*time.Minute,
|
|
s.EventsCfg.SkipMaxCompletionRatio,
|
|
s.EventsCfg.SkipMaxDurationPlayedMs,
|
|
)
|
|
lidarrCfg := lidarrconfig.New(s.Pool)
|
|
// Per-call client factory: re-reads config so an admin save in
|
|
// /admin/integrations takes effect immediately without restart.
|
|
// Returns nil when Lidarr is disabled, which the Service-layer
|
|
// methods translate to ErrLidarrDisabled.
|
|
lidarrClientFn := func() *lidarr.Client {
|
|
cfg, err := lidarrCfg.Get(context.Background())
|
|
if err != nil || !cfg.Enabled || cfg.BaseURL == "" || cfg.APIKey == "" {
|
|
return nil
|
|
}
|
|
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
|
|
}
|
|
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
|
|
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
|
|
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar)
|
|
// /api/admin/scan is the only admin route owned by the server package
|
|
// (it needs the Scanner). Register it as a single inline-middleware
|
|
// route — using r.Route("/api/admin", ...) here would create a second
|
|
// subtree that shadows every admin route registered by api.Mount.
|
|
if s.Scanner != nil {
|
|
r.With(auth.RequireUser(s.Pool), auth.RequireAdmin()).
|
|
Post("/api/admin/scan", s.handleAdminScan)
|
|
}
|
|
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer)
|
|
}
|
|
|
|
spa := web.Handler()
|
|
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
|
|
p := req.URL.Path
|
|
if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`))
|
|
return
|
|
}
|
|
spa.ServeHTTP(w, req)
|
|
})
|
|
return r
|
|
}
|
|
|
|
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// handleAdminScan runs a scan synchronously and returns the resulting stats.
|
|
// Kept synchronous for v1: libraries are small and the operator can tell when
|
|
// it's done. Async jobs with status polling are a later-milestone concern.
|
|
func (s *Server) handleAdminScan(w http.ResponseWriter, r *http.Request) {
|
|
stats, err := s.Scanner.Scan(r.Context())
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err != nil {
|
|
s.Logger.Error("admin scan failed", "err", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error(), "stats": stats})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(stats)
|
|
}
|