Add Chart.js line/bar charts for attack trends (attempts over time, hourly pattern), an SVG world map choropleth colored by attack origin country, and a collapsible filter form (date range, IP, country, username) that narrows both charts and top-N tables. New store methods: GetAttemptsOverTime, GetHourlyPattern, GetCountryStats, and filtered variants of dashboard stats/top-N queries. New JSON API endpoints at /api/charts/* and an htmx fragment at /fragments/dashboard-content for filtered table updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
83 lines
2.4 KiB
Go
83 lines
2.4 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"embed"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
|
)
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
// Server is the web dashboard HTTP server.
|
|
type Server struct {
|
|
store storage.Store
|
|
logger *slog.Logger
|
|
mux *http.ServeMux
|
|
tmpl *templateSet
|
|
}
|
|
|
|
// NewServer creates a new web Server with routes registered.
|
|
// If metricsHandler is non-nil, a /metrics endpoint is registered.
|
|
// If metricsToken is non-empty, the metrics endpoint requires Bearer token auth.
|
|
func NewServer(store storage.Store, logger *slog.Logger, metricsHandler http.Handler, metricsToken string) (*Server, error) {
|
|
tmpl, err := loadTemplates()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s := &Server{
|
|
store: store,
|
|
logger: logger,
|
|
mux: http.NewServeMux(),
|
|
tmpl: tmpl,
|
|
}
|
|
|
|
s.mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
|
s.mux.HandleFunc("GET /sessions/{id}", s.handleSessionDetail)
|
|
s.mux.HandleFunc("GET /api/sessions/{id}/events", s.handleAPISessionEvents)
|
|
s.mux.HandleFunc("GET /api/charts/attempts-over-time", s.handleAPIAttemptsOverTime)
|
|
s.mux.HandleFunc("GET /api/charts/hourly-pattern", s.handleAPIHourlyPattern)
|
|
s.mux.HandleFunc("GET /api/charts/country-stats", s.handleAPICountryStats)
|
|
s.mux.HandleFunc("GET /", s.handleDashboard)
|
|
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
|
|
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
|
|
s.mux.HandleFunc("GET /fragments/dashboard-content", s.handleFragmentDashboardContent)
|
|
|
|
if metricsHandler != nil {
|
|
h := metricsHandler
|
|
if metricsToken != "" {
|
|
h = requireBearerToken(metricsToken, h)
|
|
}
|
|
s.mux.Handle("GET /metrics", h)
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// ServeHTTP delegates to the internal mux.
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.mux.ServeHTTP(w, r)
|
|
}
|
|
|
|
// requireBearerToken wraps a handler to require a valid Bearer token.
|
|
func requireBearerToken(token string, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(auth, "Bearer ") {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
provided := auth[len("Bearer "):]
|
|
if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|