From cea20957e7bb97f9ca5ed54d53f5f6d9da782b33 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Sun, 19 Jul 2026 22:35:28 +0300 Subject: [PATCH] feat: add Web UI server with basic-auth and artist dashboard --- docs/plans/2026-07-19-notifier-webui-sync.md | 8 +- internal/web/handlers.go | 55 ++++++ internal/web/server.go | 172 ++++++++++++++++++ internal/web/server_test.go | 180 +++++++++++++++++++ internal/web/templates/dashboard.html | 49 +++++ 5 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 internal/web/handlers.go create mode 100644 internal/web/server.go create mode 100644 internal/web/server_test.go create mode 100644 internal/web/templates/dashboard.html diff --git a/docs/plans/2026-07-19-notifier-webui-sync.md b/docs/plans/2026-07-19-notifier-webui-sync.md index b49d853..c4217b0 100644 --- a/docs/plans/2026-07-19-notifier-webui-sync.md +++ b/docs/plans/2026-07-19-notifier-webui-sync.md @@ -119,10 +119,10 @@ name collisions.) - [x] run tests - must pass before task 7 ### Task 7: Web UI — server + auth + dashboard -- [ ] create `internal/web` with `Server` (net/http), `//go:embed` templates, basic-auth middleware using `ServerConfig.Username/Password` -- [ ] dashboard handler: list monitored artists with missing-release counts (join scanner result / external vs local) -- [ ] write tests: unauthenticated request → 401; authenticated → 200 with expected artist rendered -- [ ] run tests - must pass before task 8 +- [x] create `internal/web` with `Server` (net/http), `//go:embed` templates, basic-auth middleware using `ServerConfig.Username/Password` +- [x] dashboard handler: list monitored artists with missing-release counts (join scanner result / external vs local) +- [x] write tests: unauthenticated request → 401; authenticated → 200 with expected artist rendered +- [x] run tests - must pass before task 8 ### Task 8: Web UI — artist detail + archive + ignore actions - [ ] artist page: local albums (Subsonic) + found missing (MB cache) + ignore buttons diff --git a/internal/web/handlers.go b/internal/web/handlers.go new file mode 100644 index 0000000..4577a76 --- /dev/null +++ b/internal/web/handlers.go @@ -0,0 +1,55 @@ +package web + +import ( + "embed" + "fmt" + "html/template" + "net/http" + + "naviwatcher/internal/config" + "naviwatcher/internal/database" +) + +//go:embed templates/*.html +var templates embed.FS + +// dashboardTmpl is parsed once at package init from the embedded templates. +var dashboardTmpl = template.Must(template.ParseFS(templates, "templates/dashboard.html")) + +// handleDashboard renders the artist dashboard: monitored artists with their +// missing-release counts. +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { + // Only serve the index at "/" (and not e.g. "/favicon.ico" fallthroughs). + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + threshold := s.defaultThreshold() + data, err := s.buildDashboardData(r.Context(), threshold) + if err != nil { + http.Error(w, fmt.Sprintf("failed to build dashboard: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := dashboardTmpl.Execute(w, data); err != nil { + http.Error(w, fmt.Sprintf("template render error: %v", err), http.StatusInternalServerError) + } +} + +// defaultThreshold returns the fuzzy threshold to use when scanning for the +// dashboard. It is currently fixed at the engine default; later wiring can +// thread the configured threshold through the Server if desired. +func (s *Server) defaultThreshold() float64 { + return 0 // 0 → scanner.DefaultThreshold +} + +// NewServerWithConfig is a convenience constructor that accepts the full +// *config.Config (mirroring how the app constructs other components). It +// forwards the server sub-config and derives uiBaseURL from host/port. +func NewServerWithConfig(cfg *config.Config, db *database.DB) *Server { + // Build a best-effort external base URL from the server config. + base := fmt.Sprintf("http://%s:%d", cfg.Server.Host, cfg.Server.Port) + return NewServer(&cfg.Server, db, base) +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..c0988b9 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,172 @@ +// 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 +} + +// 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. +func NewServer(cfg *config.ServerConfig, db *database.DB, uiBaseURL string) *Server { + s := &Server{ + cfg: cfg, + db: db, + uiBaseURL: strings.TrimRight(uiBaseURL, "/"), + } + mux := http.NewServeMux() + mux.HandleFunc("/", s.handleDashboard) + 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(), + } + + 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 +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go new file mode 100644 index 0000000..3654913 --- /dev/null +++ b/internal/web/server_test.go @@ -0,0 +1,180 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "naviwatcher/internal/config" + "naviwatcher/internal/database" +) + +// seedArtist inserts an artist_settings row and returns its ID. +func seedArtist(t *testing.T, db *database.DB, id, name, mbid string, monitored bool) { + t.Helper() + if err := database.SaveArtistSettings(db, &database.ArtistSettings{ + ID: id, + Name: name, + MBID: mbid, + Monitored: monitored, + }); err != nil { + t.Fatalf("seed artist %s: %v", id, err) + } +} + +// seedLocalAlbum inserts a local_albums row. +func seedLocalAlbum(t *testing.T, db *database.DB, id, artistID, title string) { + t.Helper() + if err := database.SaveLocalAlbum(db, &database.LocalAlbum{ID: id, ArtistID: artistID, Title: title}); err != nil { + t.Fatalf("seed local album %s: %v", id, err) + } +} + +// seedExternalRelease inserts an external_releases row (not ignored). +func seedExternalRelease(t *testing.T, db *database.DB, rgid, artistID, title string) { + t.Helper() + if err := database.SaveExternalRelease(db, &database.ExternalRelease{ + RGID: rgid, + ArtistID: artistID, + Title: title, + }); err != nil { + t.Fatalf("seed external release %s: %v", rgid, err) + } +} + +func newServer(t *testing.T, user, pass string) (*Server, *database.DB) { + t.Helper() + db, err := database.New(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + cfg := &config.ServerConfig{Host: "0.0.0.0", Port: 8080, Username: user, Password: pass} + s := NewServer(cfg, db, "http://ui.example") + return s, db +} + +func TestDashboard_Authenticated200(t *testing.T) { + s, db := newServer(t, "admin", "secret") + seedArtist(t, db, "a1", "Radiohead", "mbid-1", true) + seedLocalAlbum(t, db, "l1", "a1", "OK Computer") + // One missing release (no local album matches "Kid A"). + seedExternalRelease(t, db, "r1", "a1", "Kid A") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Radiohead") { + t.Errorf("expected artist name in body, got:\n%s", body) + } + if !strings.Contains(body, "1") { + t.Errorf("expected missing count rendered, got:\n%s", body) + } + if !strings.Contains(body, "mbid-1") { + t.Errorf("expected MBID rendered, got:\n%s", body) + } +} + +func TestDashboard_Unauthenticated401(t *testing.T) { + s, _ := newServer(t, "admin", "secret") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + if !strings.Contains(rec.Header().Get("WWW-Authenticate"), "Basic") { + t.Errorf("expected Basic auth challenge, got headers: %v", rec.Header()) + } +} + +func TestDashboard_WrongPassword401(t *testing.T) { + s, _ := newServer(t, "admin", "secret") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.SetBasicAuth("admin", "wrong") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for wrong password, got %d", rec.Code) + } +} + +func TestDashboard_NoAuthWhenDisabled(t *testing.T) { + // When username or password is empty, auth is bypassed. + s, db := newServer(t, "", "") + seedArtist(t, db, "a1", "Boards of Canada", "", true) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 when auth disabled, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Boards of Canada") { + t.Errorf("expected artist rendered, got:\n%s", rec.Body.String()) + } +} + +func TestDashboard_SkipsUnmonitored(t *testing.T) { + s, db := newServer(t, "admin", "secret") + seedArtist(t, db, "mon", "Monitored Artist", "", true) + seedArtist(t, db, "unmon", "Unmonitored Artist", "", false) + seedExternalRelease(t, db, "r1", "unmon", "Should not appear") + seedExternalRelease(t, db, "r2", "mon", "Missing here") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := rec.Body.String() + if strings.Contains(body, "Unmonitored Artist") { + t.Errorf("unmonitored artist should not appear, got:\n%s", body) + } + if !strings.Contains(body, "Monitored Artist") { + t.Errorf("monitored artist should appear, got:\n%s", body) + } +} + +func TestDashboard_EmptyState(t *testing.T) { + s, _ := newServer(t, "admin", "secret") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "No monitored artists") { + t.Errorf("expected empty-state message, got:\n%s", rec.Body.String()) + } +} + +func TestDashboard_NotFoundForOtherPaths(t *testing.T) { + s, _ := newServer(t, "admin", "secret") + + req := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil) + req.SetBasicAuth("admin", "secret") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 for non-root path, got %d", rec.Code) + } +} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html new file mode 100644 index 0000000..4fc8f8f --- /dev/null +++ b/internal/web/templates/dashboard.html @@ -0,0 +1,49 @@ + + + + + + NaviWatcher — Dashboard + + + +

NaviWatcher

+
Monitored artists: {{ len .Artists }} · Missing releases: {{ .TotalMissing }}
+ + {{ if .Artists }} + + + + + + + + + + {{ range .Artists }} + + + + + + {{ end }} + +
ArtistMissingMusicBrainz
{{ .Name }} + {{ .MissingCount }} + {{ if .MBID }}{{ .MBID }}{{ else }}{{ end }}
+ {{ else }} +

No monitored artists yet. Run a sync to populate the dashboard.

+ {{ end }} + +