urit/internal/server/admin.go
2026-05-02 21:11:50 -04:00

465 lines
14 KiB
Go

package server
import (
"fmt"
"html/template"
"net/http"
"strconv"
"time"
)
// registerAdminRoutes adds sysop console routes to the HTTP mux.
// All admin routes require sysop-level auth (SecStatus == 255).
//
// The original TAG-BBS had no web admin — the sysop managed everything
// from the telnet Edit_Accounts menu. This console gives sysops a
// browser-based dashboard they can check without telnetting in.
func (h *HTTPServer) registerAdminRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin", h.requireSysop(h.handleAdminDashboard))
mux.HandleFunc("GET /admin/nodes", h.requireSysop(h.handleAdminNodes))
mux.HandleFunc("POST /admin/nodes/{node}/kick", h.requireSysop(h.handleAdminKickNode))
mux.HandleFunc("GET /admin/log", h.requireSysop(h.handleAdminLog))
}
// requireSysop wraps a handler with sysop-level auth enforcement.
// Returns 403 for non-sysop users, redirects to /login for anonymous.
func (h *HTTPServer) requireSysop(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
identity := h.resolveAuth(r)
if identity == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Look up the full user to check SecStatus
user, err := h.store.GetUser(identity.UserID)
if err != nil || user == nil || !user.IsSysop() {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
forbiddenTmpl.Execute(w, forbiddenData{
SystemName: h.cfg.System.Name,
Message: "Sysop access required.",
UserName: identity.UserName,
})
return
}
next(w, r)
}
}
// handleAdminDashboard shows the main sysop dashboard with system
// status, stats overview, and recent activity.
func (h *HTTPServer) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
stats, _ := h.store.GetAllStats()
userCount, _ := h.store.CountUsers()
boards, _ := h.store.ListBoards()
libs, _ := h.store.ListLibraries()
nodes := h.bbs.ActiveNodes()
callLog, _ := h.store.ListCallLog(15)
uptime := time.Since(h.bbs.StartedAt).Truncate(time.Second)
// Format total online time from seconds
totalSecs := stats["total_time_secs"]
totalHours := totalSecs / 3600
totalMins := (totalSecs % 3600) / 60
var logEntries []adminLogEntry
for _, e := range callLog {
logEntries = append(logEntries, adminLogEntry{
Time: e.CreatedAt.Format("Jan 02 15:04"),
Event: e.Event,
UserName: e.UserName,
Node: e.Node,
Detail: e.Detail,
})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
adminDashboardTmpl.Execute(w, adminDashboardData{
SystemName: h.cfg.System.Name,
Uptime: formatDuration(uptime),
NodesOnline: len(nodes),
UserCount: userCount,
BoardCount: len(boards),
LibCount: len(libs),
TotalCalls: stats["total_calls"],
GuestCalls: stats["guest_calls"],
NewCalls: stats["new_calls"],
ValidCalls: stats["valid_calls"],
NewAccounts: stats["new_accounts"],
MsgPosted: stats["messages_posted"],
MailSent: stats["mail_sent"],
FilesDown: stats["files_downloaded"],
FilesUp: stats["files_uploaded"],
TotalTime: fmt.Sprintf("%dh %dm", totalHours, totalMins),
TelnetAddr: h.cfg.Telnet.Address,
HTTPAddr: h.cfg.HTTP.Address,
RecentLog: logEntries,
})
}
// handleAdminNodes shows the live node list with kick buttons.
func (h *HTTPServer) handleAdminNodes(w http.ResponseWriter, r *http.Request) {
nodes := h.bbs.ActiveNodes()
var items []adminNodeItem
for _, n := range nodes {
name := n.UserName
if name == "" {
name = "(connecting)"
}
items = append(items, adminNodeItem{
Node: n.Node,
UserName: name,
UserID: n.UserID,
RemoteAddr: n.RemoteAddr,
ConnectedAt: n.ConnectedAt.Format("15:04:05"),
Duration: formatDuration(time.Since(n.ConnectedAt).Truncate(time.Second)),
})
}
msg := r.URL.Query().Get("msg")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
adminNodesTmpl.Execute(w, adminNodesData{
SystemName: h.cfg.System.Name,
Nodes: items,
Message: msg,
})
}
// handleAdminKickNode disconnects a node via POST.
func (h *HTTPServer) handleAdminKickNode(w http.ResponseWriter, r *http.Request) {
nodeStr := r.PathValue("node")
node, err := strconv.Atoi(nodeStr)
if err != nil {
http.Redirect(w, r, "/admin/nodes?msg=Invalid+node", http.StatusSeeOther)
return
}
if err := h.bbs.DisconnectNode(node); err != nil {
http.Redirect(w, r, "/admin/nodes?msg=Node+not+found", http.StatusSeeOther)
return
}
http.Redirect(w, r,
fmt.Sprintf("/admin/nodes?msg=Node+%d+disconnected", node),
http.StatusSeeOther)
}
// handleAdminLog shows an extended call log view.
func (h *HTTPServer) handleAdminLog(w http.ResponseWriter, r *http.Request) {
callLog, _ := h.store.ListCallLog(50)
var entries []adminLogEntry
for _, e := range callLog {
entries = append(entries, adminLogEntry{
Time: e.CreatedAt.Format("Jan 02 15:04:05"),
Event: e.Event,
UserName: e.UserName,
Node: e.Node,
Detail: e.Detail,
})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
adminLogTmpl.Execute(w, adminLogData{
SystemName: h.cfg.System.Name,
Entries: entries,
})
}
// formatDuration renders a duration as a human-readable string.
func formatDuration(d time.Duration) string {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
mins := int(d.Minutes()) % 60
secs := int(d.Seconds()) % 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm %ds", hours, mins, secs)
}
return fmt.Sprintf("%dm %ds", mins, secs)
}
// --- Admin data types ---
type adminDashboardData struct {
SystemName string
Uptime string
NodesOnline int
UserCount int
BoardCount int
LibCount int
TotalCalls int64
GuestCalls int64
NewCalls int64
ValidCalls int64
NewAccounts int64
MsgPosted int64
MailSent int64
FilesDown int64
FilesUp int64
TotalTime string
TelnetAddr string
HTTPAddr string
RecentLog []adminLogEntry
}
type adminLogEntry struct {
Time string
Event string
UserName string
Node int
Detail string
}
type adminNodesData struct {
SystemName string
Nodes []adminNodeItem
Message string
}
type adminNodeItem struct {
Node int
UserName string
UserID int64
RemoteAddr string
ConnectedAt string
Duration string
}
type adminLogData struct {
SystemName string
Entries []adminLogEntry
}
// --- Admin templates ---
// These use the same terminal-green aesthetic as the rest of the
// HTTP interface, but with a red accent for the admin header to
// make it visually distinct.
func adminHead(title string) string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>` + title + ` — Admin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Courier New", Courier, monospace;
background: #0a0a0a;
color: #33ff33;
min-height: 100vh;
padding: 1rem;
}
a { color: #33ff33; text-decoration: none; }
a:hover { text-decoration: underline; }
.panel {
max-width: 960px;
margin: 0 auto 1rem;
padding: 1.5rem;
border: 1px solid #33ff33;
}
.admin-header {
border-color: #ff3333;
border-bottom: 1px solid #331111;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
}
.admin-header h1 { color: #ff3333; font-size: 1.2rem; }
.admin-header .subtitle { color: #993333; font-size: 0.85rem; margin-top: 0.3rem; }
.admin-nav {
margin: 0.75rem 0;
font-size: 0.85rem;
color: #1a7a1a;
}
.admin-nav a { margin-right: 1.5rem; }
.admin-nav a.active { color: #33ff33; text-decoration: underline; }
h2 { color: #33ff33; font-size: 1rem; margin: 1rem 0 0.5rem; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
margin: 0.5rem 0;
}
.stat-box {
border: 1px solid #1a3a1a;
padding: 0.5rem 0.75rem;
}
.stat-label { color: #1a7a1a; font-size: 0.75rem; }
.stat-value { color: #33ff33; font-size: 1.1rem; margin-top: 0.2rem; }
.tbl { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin: 0.5rem 0; }
.tbl th {
color: #1a7a1a; border-bottom: 1px solid #1a7a1a;
padding: 0.3rem 0.5rem; font-weight: normal; text-align: left;
}
.tbl td { padding: 0.3rem 0.5rem; border-bottom: 1px solid #111; }
.tbl tr:hover td { background: #111; }
.center { text-align: center; }
.right { text-align: right; }
.nowrap { white-space: nowrap; }
.event-login { color: #33ff33; }
.event-logoff { color: #1a7a1a; }
.event-kicked { color: #ff3333; }
.btn-kick {
background: #1a1111;
border: 1px solid #ff3333;
color: #ff3333;
padding: 0.2rem 0.6rem;
font-family: "Courier New", Courier, monospace;
font-size: 0.8rem;
cursor: pointer;
}
.btn-kick:hover { background: #331111; }
.msg { color: #ffff33; margin: 0.5rem 0; font-size: 0.85rem; }
.empty { color: #1a7a1a; padding: 1rem 0; }
</style>
</head>
<body>
`
}
var adminNav = `
<div class="panel admin-header">
<h1>{{.SystemName}} — Sysop Console</h1>
<div class="admin-nav">
<a href="/admin">Dashboard</a>
<a href="/admin/nodes">Nodes</a>
<a href="/admin/log">Call Log</a>
<a href="/">Public Site</a>
<a href="/logout">Log Out</a>
</div>
</div>
`
var adminDashboardTmpl = template.Must(template.New("admin-dash").Parse(adminHead("Dashboard") + adminNav + `
<div class="panel">
<h2>System Status</h2>
<div class="stats-grid">
<div class="stat-box"><div class="stat-label">Uptime</div><div class="stat-value">{{.Uptime}}</div></div>
<div class="stat-box"><div class="stat-label">Nodes Online</div><div class="stat-value">{{.NodesOnline}}</div></div>
<div class="stat-box"><div class="stat-label">Registered Users</div><div class="stat-value">{{.UserCount}}</div></div>
<div class="stat-box"><div class="stat-label">Boards</div><div class="stat-value">{{.BoardCount}}</div></div>
<div class="stat-box"><div class="stat-label">Libraries</div><div class="stat-value">{{.LibCount}}</div></div>
<div class="stat-box"><div class="stat-label">Telnet</div><div class="stat-value">{{.TelnetAddr}}</div></div>
<div class="stat-box"><div class="stat-label">HTTP</div><div class="stat-value">{{.HTTPAddr}}</div></div>
</div>
</div>
<div class="panel">
<h2>Counters</h2>
<div class="stats-grid">
<div class="stat-box"><div class="stat-label">Total Calls</div><div class="stat-value">{{.TotalCalls}}</div></div>
<div class="stat-box"><div class="stat-label">Guest Calls</div><div class="stat-value">{{.GuestCalls}}</div></div>
<div class="stat-box"><div class="stat-label">New User Calls</div><div class="stat-value">{{.NewCalls}}</div></div>
<div class="stat-box"><div class="stat-label">Valid Calls</div><div class="stat-value">{{.ValidCalls}}</div></div>
<div class="stat-box"><div class="stat-label">New Accounts</div><div class="stat-value">{{.NewAccounts}}</div></div>
<div class="stat-box"><div class="stat-label">Messages Posted</div><div class="stat-value">{{.MsgPosted}}</div></div>
<div class="stat-box"><div class="stat-label">Mail Sent</div><div class="stat-value">{{.MailSent}}</div></div>
<div class="stat-box"><div class="stat-label">Files Downloaded</div><div class="stat-value">{{.FilesDown}}</div></div>
<div class="stat-box"><div class="stat-label">Files Uploaded</div><div class="stat-value">{{.FilesUp}}</div></div>
<div class="stat-box"><div class="stat-label">Total Online Time</div><div class="stat-value">{{.TotalTime}}</div></div>
</div>
</div>
<div class="panel">
<h2>Recent Activity</h2>
{{if .RecentLog}}
<table class="tbl">
<thead><tr>
<th>Time</th><th>Event</th><th>User</th><th class="center">Node</th><th>Detail</th>
</tr></thead>
<tbody>
{{range .RecentLog}}
<tr>
<td class="nowrap">{{.Time}}</td>
<td class="event-{{.Event}}">{{.Event}}</td>
<td>{{.UserName}}</td>
<td class="center">{{.Node}}</td>
<td>{{.Detail}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty">No activity recorded yet.</p>
{{end}}
<p style="margin-top:0.5rem; font-size:0.8rem;"><a href="/admin/log">Full call log &gt;</a></p>
</div>
</body>
</html>
`))
var adminNodesTmpl = template.Must(template.New("admin-nodes").Parse(adminHead("Nodes") + adminNav + `
<div class="panel">
<h2>Connected Nodes</h2>
{{if .Message}}<p class="msg">{{.Message}}</p>{{end}}
{{if .Nodes}}
<table class="tbl">
<thead><tr>
<th class="center">Node</th><th>User</th><th class="center">ID</th>
<th>Address</th><th>Connected</th><th>Duration</th><th></th>
</tr></thead>
<tbody>
{{range .Nodes}}
<tr>
<td class="center">{{.Node}}</td>
<td>{{.UserName}}</td>
<td class="center">{{.UserID}}</td>
<td>{{.RemoteAddr}}</td>
<td class="nowrap">{{.ConnectedAt}}</td>
<td class="nowrap">{{.Duration}}</td>
<td class="right">
<form method="POST" action="/admin/nodes/{{.Node}}/kick"
onsubmit="return confirm('Disconnect node {{.Node}} ({{.UserName}})?')">
<button type="submit" class="btn-kick">Kick</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty">No nodes connected.</p>
{{end}}
</div>
</body>
</html>
`))
var adminLogTmpl = template.Must(template.New("admin-log").Parse(adminHead("Call Log") + adminNav + `
<div class="panel">
<h2>Call Log (Last 50)</h2>
{{if .Entries}}
<table class="tbl">
<thead><tr>
<th>Time</th><th>Event</th><th>User</th><th class="center">Node</th><th>Detail</th>
</tr></thead>
<tbody>
{{range .Entries}}
<tr>
<td class="nowrap">{{.Time}}</td>
<td class="event-{{.Event}}">{{.Event}}</td>
<td>{{.UserName}}</td>
<td class="center">{{.Node}}</td>
<td>{{.Detail}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty">No activity recorded yet.</p>
{{end}}
</div>
</body>
</html>
`))