Files
NaviWatcher/internal/web/server.go
Vladimir Zagainov ce1c39e14b fix: address code review findings
- Preserve MBID and last_synced across periodic artist syncs (INSERT OR
  REPLACE was wiping them, forcing MusicBrainz re-resolution every cycle)
- Exclude unmonitored artists from unnotified-release digest query
- Require telegram.cron_schedule when telegram.enabled is true
- Add same-origin CSRF defense to state-changing web POST routes
- Skip WAL/busy_timeout pragmas for :memory: databases (no-op there)
- Scan mbid as sql.NullString in GetAllArtistSettings to tolerate NULLs
2026-07-19 23:51:15 +03:00

225 lines
7.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"
"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
}