This repository has been archived on 2026-03-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
oubliette/internal/web/handlers.go
Torjus Håkestad 1b28f10ca8 refactor: migrate module path from git.t-juice.club to code.t-juice.club
Update Go module path and all import references to reflect the migration
from Gitea (git.t-juice.club) to Forgejo (code.t-juice.club).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:51:23 +01:00

442 lines
13 KiB
Go

package web
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"time"
"code.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)
}
}