465 lines
14 KiB
Go
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 ></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>
|
|
`))
|