// Package web implements the NaviWatcher HTTP dashboard: a net/http server with // embedded templates, basic-auth protection, and a dashboard that lists // monitored artists with their missing-release counts. package web import ( "context" "crypto/subtle" "encoding/base64" "fmt" "log" "net/http" "net/url" "strings" "time" "naviwatcher/internal/config" "naviwatcher/internal/database" "naviwatcher/internal/scanner" ) // Server is the NaviWatcher web dashboard HTTP server. type Server struct { cfg *config.ServerConfig db *database.DB mux *http.ServeMux // uiBaseURL is the externally reachable base URL of the dashboard (scheme + // host), used to build links in notifications and elsewhere. Optional. uiBaseURL string // threshold is the fuzzy-similarity cutoff used when scanning for missing // releases; 0 means use the scanner default. threshold float64 } // NewServer constructs a dashboard Server bound to the given DB and server // config. uiBaseURL is the externally reachable origin (e.g. // "http://localhost:8080") used when rendering absolute links; pass "" to omit. // threshold is the fuzzy-similarity cutoff (0 → scanner default). func NewServer(cfg *config.ServerConfig, db *database.DB, uiBaseURL string, threshold float64) *Server { s := &Server{ cfg: cfg, db: db, uiBaseURL: strings.TrimRight(uiBaseURL, "/"), threshold: threshold, } // Warn loudly when auth is disabled but the server is reachable from outside // the host: Basic auth is silently skipped when Username/Password are empty, // so an operator who forgets credentials on a non-loopback bind would expose // DB-mutating POST routes (ignore/restore/toggle) to the network. if (cfg.Username == "" || cfg.Password == "") && cfg.Host != "localhost" && cfg.Host != "127.0.0.1" && cfg.Host != "::1" { log.Printf("WARNING: Web UI authentication is DISABLED (server.username/password empty) and the server is bound to %q. The dashboard and its state-changing routes are exposed to the network. Set credentials or bind to localhost.", cfg.Host) } mux := http.NewServeMux() mux.HandleFunc("/", s.handleDashboard) mux.HandleFunc("/artist/{id}", s.handleArtist) mux.HandleFunc("/artist/{id}/ignore", s.ignoreOrRestore) mux.HandleFunc("/artist/{id}/restore", s.ignoreOrRestore) mux.HandleFunc("/artist/{id}/ignore-singles", s.toggleIgnoreSingles) mux.HandleFunc("/archive", s.handleArchive) s.mux = mux return s } // Handler returns the http.Handler (auth-wrapped mux) for the server. It is // exported so callers can embed the dashboard in a larger handler tree or test // it directly via httptest. func (s *Server) Handler() http.Handler { return s.authMiddleware(s.mux) } // Addr returns the listen address ("host:port") for this server. func (s *Server) Addr() string { return fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) } // Start begins listening and serving until the context is cancelled, then shuts // down gracefully. It returns any unrecoverable serve error (a clean shutdown // due to ctx cancellation returns nil). func (s *Server) Start(ctx context.Context) error { srv := &http.Server{ Addr: s.Addr(), Handler: s.Handler(), ReadTimeout: 15 * time.Second, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { log.Printf("web server shutdown error: %v", err) } }() log.Printf("Web UI listening on %s", s.Addr()) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { return fmt.Errorf("web server serve: %w", err) } return nil } // authMiddleware enforces HTTP Basic auth per RFC 7617 using a constant-time // comparison of the base64-encoded "user:pass" credential. When either // Username or Password is empty, auth is disabled (useful for local/dev). func (s *Server) authMiddleware(next http.Handler) http.Handler { user := s.cfg.Username pass := s.cfg.Password if user == "" || pass == "" { return next } // Precompute the expected Authorization header value once. want := "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { header := r.Header.Get("Authorization") if header == "" { unauthorized(w) return } // Constant-time compare of the full header value. if subtle.ConstantTimeCompare([]byte(header), []byte(want)) != 1 { unauthorized(w) return } next.ServeHTTP(w, r) }) } // unauthorized writes a 401 with a Basic auth challenge. func unauthorized(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="NaviWatcher"`) w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte("401 Unauthorized\n")) } // sameOrigin returns true when the request's Origin (or, lacking that, Referer) // header matches the server's own origin. State-changing POST routes use this // as a lightweight CSRF defense: a cross-site request from a logged-in // operator's browser will carry a different Origin/Referer and be rejected. // When the header is absent (e.g. a same-origin form POST from older browsers // or curl), the request is allowed rather than blocked, since the dashboard // only ever issues same-origin form posts. func (s *Server) sameOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { origin = r.Header.Get("Referer") } if origin == "" { return true } got, err := url.Parse(origin) if err != nil || got.Host == "" { return false } // Accept the actual Host the client reached (covers 0.0.0.0 bind with // localhost/127.0.0.1 access) as well as the configured bind address. gotHost := got.Host reqHost := r.Host if reqHost == "" { reqHost = s.Addr() } return gotHost == reqHost || gotHost == s.Addr() } // ArtistSummary is the dashboard projection of a single monitored artist and // its missing-release count. type ArtistSummary struct { ID string Name string MBID string MissingCount int Monitored bool } // DashboardData is the view model passed to the dashboard template. type DashboardData struct { Artists []ArtistSummary // TotalMissing is the sum of all artists' missing counts. TotalMissing int // UIBaseURL is the externally reachable origin, for building links. UIBaseURL string } // buildDashboardData computes the dashboard view model: every monitored artist // joined with its current missing-release count (from the scanner). func (s *Server) buildDashboardData(ctx context.Context, threshold float64) (*DashboardData, error) { settings, err := database.GetAllArtistSettings(s.db) if err != nil { return nil, fmt.Errorf("load artist settings: %w", err) } // Compute missing releases once and group by artist. missing, err := scanner.ScanAll(ctx, s.db, threshold) if err != nil { return nil, fmt.Errorf("scan: %w", err) } missingByArtist := make(map[string]int) for _, m := range missing { missingByArtist[m.ArtistID]++ } data := &DashboardData{UIBaseURL: s.uiBaseURL} for _, a := range settings { if !a.Monitored { continue } count := missingByArtist[a.ID] data.Artists = append(data.Artists, ArtistSummary{ ID: a.ID, Name: a.Name, MBID: a.MBID, MissingCount: count, Monitored: true, }) data.TotalMissing += count } return data, nil }