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 `
| Time | Event | User | Node | Detail |
|---|---|---|---|---|
| {{.Time}} | {{.Event}} | {{.UserName}} | {{.Node}} | {{.Detail}} |
No activity recorded yet.
{{end}}{{.Message}}
{{end}} {{if .Nodes}}| Node | User | ID | Address | Connected | Duration | |
|---|---|---|---|---|---|---|
| {{.Node}} | {{.UserName}} | {{.UserID}} | {{.RemoteAddr}} | {{.ConnectedAt}} | {{.Duration}} |
No nodes connected.
{{end}}