- notifier: show artist display names (not internal IDs) in digest; resolve names from artist_settings and fall back to ID when unavailable - notifier: skip sending an empty digest to avoid daily spam - config: require telegram token/chat_id when enabled - web: warn loudly when auth is disabled on a non-loopback bind; add HTTP server timeouts - web: treat SetReleaseIgnored "release not found" as benign redirect (0 rows) - musicbrainz: reject low-score/name-mismatched MBID resolutions instead of silently caching the wrong artist - database: remove dead duplicate err check; harden DSN param appending - musicbrainz: check rows.Err() after iterating existing releases
195 lines
6.4 KiB
Go
195 lines
6.4 KiB
Go
// 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"
|
|
"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"))
|
|
}
|
|
|
|
// 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
|
|
}
|