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 24c166b86b feat: add session replay with terminal playback via xterm.js
Persist byte-level I/O events from SSH sessions to SQLite and add a web
UI to replay them with original timing. Events are buffered in memory
and flushed every 2s to avoid blocking SSH I/O on database writes.

- Add session_events table (migration 002)
- Add SessionEvent type and storage methods (SQLite + MemoryStore)
- Change RecordingChannel to support multiple callbacks
- Add EventRecorder for buffered event persistence
- Add session detail page with xterm.js terminal replay
- Add /api/sessions/{id}/events JSON endpoint
- Linkify session IDs in dashboard and active sessions
- Vendor xterm.js v5.3.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 22:09:24 +01:00

195 lines
5.4 KiB
Go

package web
import (
"encoding/base64"
"encoding/json"
"net/http"
"git.t-juice.club/torjus/oubliette/internal/storage"
)
type dashboardData struct {
Stats *storage.DashboardStats
TopUsernames []storage.TopEntry
TopPasswords []storage.TopEntry
TopIPs []storage.TopEntry
ActiveSessions []storage.Session
RecentSessions []storage.Session
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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
}
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,
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) {
stats, err := s.store.GetDashboardStats(r.Context())
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) {
sessions, err := s.store.GetRecentSessions(r.Context(), 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)
}
}
type sessionDetailData struct {
Session *storage.Session
Logs []storage.SessionLog
EventCount int
}
func (s *Server) handleSessionDetail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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"`
}
func (s *Server) handleAPISessionEvents(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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)
}
}