package web import ( "context" "encoding/base64" "encoding/json" "net/http" "strconv" "time" "git.t-juice.club/torjus/oubliette/internal/storage" ) // dbContext returns a context detached from the HTTP request lifecycle with a // 30-second timeout. This prevents HTMX polling from canceling in-flight DB // queries when the browser aborts the previous XHR. func dbContext(r *http.Request) (context.Context, context.CancelFunc) { return context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Second) } type dashboardData struct { Stats *storage.DashboardStats TopUsernames []storage.TopEntry TopPasswords []storage.TopEntry TopIPs []storage.TopEntry TopCountries []storage.TopEntry TopExecCommands []storage.TopEntry ActiveSessions []storage.Session RecentSessions []storage.Session } func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { ctx, cancel := dbContext(r) defer cancel() stats, err := s.store.GetDashboardStats(ctx) if err != nil { s.logger.Error("failed to get dashboard stats", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } topUsernames, err := s.store.GetTopUsernames(ctx, 10) if err != nil { s.logger.Error("failed to get top usernames", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } topPasswords, err := s.store.GetTopPasswords(ctx, 10) if err != nil { s.logger.Error("failed to get top passwords", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } topIPs, err := s.store.GetTopIPs(ctx, 10) if err != nil { s.logger.Error("failed to get top IPs", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } topCountries, err := s.store.GetTopCountries(ctx, 10) if err != nil { s.logger.Error("failed to get top countries", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } topExecCommands, err := s.store.GetTopExecCommands(ctx, 10) if err != nil { s.logger.Error("failed to get top exec commands", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } activeSessions, err := s.store.GetRecentSessions(ctx, 50, true) if err != nil { s.logger.Error("failed to get active sessions", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } recentSessions, err := s.store.GetRecentSessions(ctx, 50, false) if err != nil { s.logger.Error("failed to get recent sessions", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } data := dashboardData{ Stats: stats, TopUsernames: topUsernames, TopPasswords: topPasswords, TopIPs: topIPs, TopCountries: topCountries, TopExecCommands: topExecCommands, ActiveSessions: activeSessions, RecentSessions: recentSessions, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.dashboard.ExecuteTemplate(w, "layout.html", data); err != nil { s.logger.Error("failed to render dashboard", "err", err) } } func (s *Server) handleFragmentStats(w http.ResponseWriter, r *http.Request) { ctx, cancel := dbContext(r) defer cancel() stats, err := s.store.GetDashboardStats(ctx) if err != nil { s.logger.Error("failed to get dashboard stats", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.dashboard.ExecuteTemplate(w, "stats", stats); err != nil { s.logger.Error("failed to render stats fragment", "err", err) } } func (s *Server) handleFragmentActiveSessions(w http.ResponseWriter, r *http.Request) { ctx, cancel := dbContext(r) defer cancel() sessions, err := s.store.GetRecentSessions(ctx, 50, true) if err != nil { s.logger.Error("failed to get active sessions", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.dashboard.ExecuteTemplate(w, "active_sessions", sessions); err != nil { s.logger.Error("failed to render active sessions fragment", "err", err) } } func (s *Server) handleFragmentRecentSessions(w http.ResponseWriter, r *http.Request) { ctx, cancel := dbContext(r) defer cancel() f := parseDashboardFilter(r) sessions, err := s.store.GetFilteredSessions(ctx, 50, false, f) if err != nil { s.logger.Error("failed to get filtered sessions", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.dashboard.ExecuteTemplate(w, "recent_sessions", sessions); err != nil { s.logger.Error("failed to render recent sessions fragment", "err", err) } } type sessionDetailData struct { Session *storage.Session Logs []storage.SessionLog EventCount int } func (s *Server) handleSessionDetail(w http.ResponseWriter, r *http.Request) { ctx, cancel := dbContext(r) defer cancel() sessionID := r.PathValue("id") session, err := s.store.GetSession(ctx, sessionID) if err != nil { s.logger.Error("failed to get session", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } if session == nil { http.NotFound(w, r) return } logs, err := s.store.GetSessionLogs(ctx, sessionID) if err != nil { s.logger.Error("failed to get session logs", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } events, err := s.store.GetSessionEvents(ctx, sessionID) if err != nil { s.logger.Error("failed to get session events", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } data := sessionDetailData{ Session: session, Logs: logs, EventCount: len(events), } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.sessionDetail.ExecuteTemplate(w, "layout.html", data); err != nil { s.logger.Error("failed to render session detail", "err", err) } } type apiEvent struct { T int64 `json:"t"` D int `json:"d"` Data string `json:"data"` } type apiEventsResponse struct { Events []apiEvent `json:"events"` } // parseDateParam parses a "YYYY-MM-DD" query parameter into a *time.Time. func parseDateParam(r *http.Request, name string) *time.Time { v := r.URL.Query().Get(name) if v == "" { return nil } t, err := time.Parse("2006-01-02", v) if err != nil { return nil } // For "until" dates, set to end of day. if name == "until" { t = t.Add(24*time.Hour - time.Second) } return &t } func parseDashboardFilter(r *http.Request) storage.DashboardFilter { return storage.DashboardFilter{ Since: parseDateParam(r, "since"), Until: parseDateParam(r, "until"), IP: r.URL.Query().Get("ip"), Country: r.URL.Query().Get("country"), Username: r.URL.Query().Get("username"), HumanScoreAboveZero: r.URL.Query().Get("human_score") == "1", SortBy: r.URL.Query().Get("sort"), } } type apiTimeSeriesPoint struct { Date string `json:"date"` Count int64 `json:"count"` } type apiAttemptsOverTimeResponse struct { Points []apiTimeSeriesPoint `json:"points"` } func (s *Server) handleAPIAttemptsOverTime(w http.ResponseWriter, r *http.Request) { days := 30 if v := r.URL.Query().Get("days"); v != "" { if d, err := strconv.Atoi(v); err == nil && d > 0 && d <= 365 { days = d } } since := parseDateParam(r, "since") until := parseDateParam(r, "until") ctx, cancel := dbContext(r) defer cancel() points, err := s.store.GetAttemptsOverTime(ctx, days, since, until) if err != nil { s.logger.Error("failed to get attempts over time", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } resp := apiAttemptsOverTimeResponse{Points: make([]apiTimeSeriesPoint, len(points))} for i, p := range points { resp.Points[i] = apiTimeSeriesPoint{ Date: p.Timestamp.Format("2006-01-02"), Count: p.Count, } } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(resp); err != nil { s.logger.Error("failed to encode attempts over time", "err", err) } } type apiHourlyCount struct { Hour int `json:"hour"` Count int64 `json:"count"` } type apiHourlyPatternResponse struct { Hours []apiHourlyCount `json:"hours"` } func (s *Server) handleAPIHourlyPattern(w http.ResponseWriter, r *http.Request) { ctx, cancel := dbContext(r) defer cancel() since := parseDateParam(r, "since") until := parseDateParam(r, "until") counts, err := s.store.GetHourlyPattern(ctx, since, until) if err != nil { s.logger.Error("failed to get hourly pattern", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } resp := apiHourlyPatternResponse{Hours: make([]apiHourlyCount, len(counts))} for i, c := range counts { resp.Hours[i] = apiHourlyCount{Hour: c.Hour, Count: c.Count} } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(resp); err != nil { s.logger.Error("failed to encode hourly pattern", "err", err) } } type apiCountryCount struct { Country string `json:"country"` Count int64 `json:"count"` } type apiCountryStatsResponse struct { Countries []apiCountryCount `json:"countries"` } func (s *Server) handleAPICountryStats(w http.ResponseWriter, r *http.Request) { ctx, cancel := dbContext(r) defer cancel() counts, err := s.store.GetCountryStats(ctx) if err != nil { s.logger.Error("failed to get country stats", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } resp := apiCountryStatsResponse{Countries: make([]apiCountryCount, len(counts))} for i, c := range counts { resp.Countries[i] = apiCountryCount{Country: c.Country, Count: c.Count} } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(resp); err != nil { s.logger.Error("failed to encode country stats", "err", err) } } func (s *Server) handleFragmentDashboardContent(w http.ResponseWriter, r *http.Request) { ctx, cancel := dbContext(r) defer cancel() f := parseDashboardFilter(r) stats, err := s.store.GetFilteredDashboardStats(ctx, f) if err != nil { s.logger.Error("failed to get filtered stats", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } topUsernames, err := s.store.GetFilteredTopUsernames(ctx, 10, f) if err != nil { s.logger.Error("failed to get filtered top usernames", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } topPasswords, err := s.store.GetFilteredTopPasswords(ctx, 10, f) if err != nil { s.logger.Error("failed to get filtered top passwords", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } topIPs, err := s.store.GetFilteredTopIPs(ctx, 10, f) if err != nil { s.logger.Error("failed to get filtered top IPs", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } topCountries, err := s.store.GetFilteredTopCountries(ctx, 10, f) if err != nil { s.logger.Error("failed to get filtered top countries", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } data := dashboardData{ Stats: stats, TopUsernames: topUsernames, TopPasswords: topPasswords, TopIPs: topIPs, TopCountries: topCountries, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.dashboard.ExecuteTemplate(w, "dashboard_content", data); err != nil { s.logger.Error("failed to render dashboard content fragment", "err", err) } } func (s *Server) handleAPISessionEvents(w http.ResponseWriter, r *http.Request) { ctx, cancel := dbContext(r) defer cancel() sessionID := r.PathValue("id") events, err := s.store.GetSessionEvents(ctx, sessionID) if err != nil { s.logger.Error("failed to get session events", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } resp := apiEventsResponse{Events: make([]apiEvent, len(events))} var baseTime int64 for i, e := range events { ms := e.Timestamp.UnixMilli() if i == 0 { baseTime = ms } resp.Events[i] = apiEvent{ T: ms - baseTime, D: e.Direction, Data: base64.StdEncoding.EncodeToString(e.Data), } } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(resp); err != nil { s.logger.Error("failed to encode session events", "err", err) } }