864 lines
25 KiB
Go
864 lines
25 KiB
Go
// Package menu implements the main BBS menu and command dispatch.
|
|
//
|
|
// This replaces MENU.C from the original TAG-BBS. The original used
|
|
// a FOREVER loop reading single keystrokes and dispatching to *Com()
|
|
// functions (ACom, BCom, MCom, PCom, etc.) via a switch statement.
|
|
//
|
|
// The modernized version uses the same single-keypress model — a user
|
|
// presses a letter and the command runs immediately, no Enter required.
|
|
// Commands are organized into a dispatch table rather than a monolithic
|
|
// switch, making it easy to add new commands or adjust access levels.
|
|
//
|
|
// The menu prompt shows the system name and user handle, mirroring the
|
|
// original's: "SYSTEM NAME Menu [?] "
|
|
package menu
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/urit/urit/internal/auth"
|
|
"github.com/urit/urit/internal/config"
|
|
"github.com/urit/urit/internal/models"
|
|
"github.com/urit/urit/internal/session"
|
|
"github.com/urit/urit/internal/store"
|
|
)
|
|
|
|
const (
|
|
idleTimeout = 120 * time.Second
|
|
inputTimeout = 60 * time.Second
|
|
)
|
|
|
|
// Context holds everything a menu command needs to operate.
|
|
// This replaces the original's global variables (extern struct System,
|
|
// extern struct User, extern long Time_*, etc.).
|
|
type Context struct {
|
|
Sess *session.Session
|
|
User *models.User
|
|
Store store.Store
|
|
Cfg *config.Config
|
|
Auth *auth.Result
|
|
Nodes NodeManager // Node management (list, disconnect, message)
|
|
Tokens WebTokenizer // Web access token generation (for HTTP downloads)
|
|
Chat ChatAgent // Inter-node chat
|
|
}
|
|
|
|
// WebTokenizer generates web access tokens for HTTP file downloads.
|
|
// Implemented by the server, used by the menu layer to let telnet
|
|
// users authenticate their browser sessions.
|
|
type WebTokenizer interface {
|
|
GenerateWebToken(userID int64, userName string, secLibrary int) (token string, err error)
|
|
HTTPAddress() string
|
|
}
|
|
|
|
// ChatAgent provides inter-node chat operations.
|
|
// Implemented by the server, used by the menu layer to let users
|
|
// page and chat with other connected nodes.
|
|
type ChatAgent interface {
|
|
EnterChat(node int) <-chan string
|
|
LinkChat(nodeA, nodeB int) bool
|
|
SendChat(fromNode int, msg string) bool
|
|
EndChat(node int)
|
|
ChatPartner(node int) int
|
|
IsInChat(node int) bool
|
|
}
|
|
|
|
// command defines a single menu entry.
|
|
type command struct {
|
|
Key byte // The keystroke that triggers it
|
|
Label string // Display name
|
|
Description string // One-line help text
|
|
MinSec int // Minimum SecStatus required (0 = everyone)
|
|
GuestOK bool // Whether guests can use it
|
|
Handler func(ctx *Context) error
|
|
}
|
|
|
|
// commands is the dispatch table. Order here determines help display order.
|
|
// This replaces the switch(toupper(input&0x7f)) block in MENU.C.
|
|
// Populated in init() to avoid an initialization cycle (cmdHelp references commands).
|
|
var commands []command
|
|
|
|
func init() {
|
|
commands = []command{
|
|
// Accessible to all including guests
|
|
{'?', "Help", "Show command list", 0, true, cmdHelp},
|
|
{'I', "Info", "System information", 0, true, cmdInfo},
|
|
{'S', "Stats", "System statistics", 0, true, cmdStats},
|
|
{'T', "Time", "Time statistics", 0, true, cmdTime},
|
|
{'W', "Who", "Who's online", 0, true, cmdWho},
|
|
|
|
// Require an account (no guests)
|
|
{'A', "Account", "Your account info", 0, false, cmdAccount},
|
|
{'P', "Mail", "Private mail", 0, false, cmdMail},
|
|
{'M', "Messages", "Message boards", 0, false, cmdMessages},
|
|
{'B', "Bulletins", "Bulletin listings", 0, false, cmdBulletins},
|
|
{'L', "Library", "File library", 0, false, cmdLibrary},
|
|
{'D', "Download", "Get web download link", 0, false, cmdDownloadToken},
|
|
{'C', "Chat", "Page/chat with other users", 0, false, cmdChat},
|
|
{'U', "Users", "User listings", 0, false, cmdUsers},
|
|
{'F', "Feedback", "Send note to sysop", 0, false, cmdFeedback},
|
|
|
|
// Guest-only: create permanent account (replaces JCom)
|
|
{'J', "Join", "Create a permanent account", 0, true, cmdJoin},
|
|
|
|
// Available to everyone
|
|
{'G', "Goodbye", "Log off", 0, true, cmdGoodbye},
|
|
|
|
// Sysop only
|
|
{'E', "Sysop", "Sysop management menu", 255, false, cmdSysopMenu},
|
|
}
|
|
}
|
|
|
|
// Run is the main menu loop. It replaces the FOREVER loop in Menu().
|
|
//
|
|
// The original's flow was:
|
|
// 1. Clear_Online_Status() — reset time tracking
|
|
// 2. Check mail count and auto-enter mail if waiting
|
|
// 3. FOREVER: print prompt, ReadChar, dispatch via switch
|
|
//
|
|
// Our flow matches this but uses the dispatch table instead of a switch.
|
|
func Run(ctx *Context) {
|
|
// Display the main menu help file if it exists (first time)
|
|
ctx.Sess.SendFile(ctx.Cfg.System.Screens + "mainmenu.ans")
|
|
|
|
for {
|
|
// Print the prompt — mirrors: "SYSTEM_NAME Menu [?] "
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.Printf("\r\n%s ", ctx.Cfg.System.Name)
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.Printf("[%s]", ctx.User.Name)
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString(" [?] ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
// Single keypress — no Enter required, just like the original
|
|
ch, err := ctx.Sess.ReadKey(idleTimeout)
|
|
if err != nil {
|
|
// Timeout or disconnect
|
|
if err == session.ErrTimeout {
|
|
ctx.Sess.WriteString("\r\nIdle timeout — goodbye!\r\n")
|
|
ctx.Sess.Close(session.DisconnectTimeout)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Check time remaining (replaces Check_Online_Status())
|
|
ctx.Sess.CheckTime()
|
|
|
|
// Find and run the matching command
|
|
handled := false
|
|
upper := toUpper(ch)
|
|
|
|
for _, cmd := range commands {
|
|
if cmd.Key != upper {
|
|
continue
|
|
}
|
|
|
|
// Security check
|
|
if cmd.MinSec > 0 && ctx.User.SecStatus < cmd.MinSec {
|
|
// Hidden command — don't even acknowledge the keystroke.
|
|
// Matches the original: if(User.Sec_Status<255) return FAILURE;
|
|
handled = true
|
|
break
|
|
}
|
|
|
|
// Guest check
|
|
if !cmd.GuestOK && ctx.Auth.IsGuest {
|
|
// J (Join) is the exception — it's GuestOK but only
|
|
// useful for guests. Other non-guest commands silently
|
|
// reject guests. But let's be friendly about it.
|
|
if cmd.Key == 'J' {
|
|
// J is guest-OK, so this won't hit. But other
|
|
// non-guest commands get a message.
|
|
}
|
|
ctx.Sess.Color(session.AnsiFgYellow)
|
|
ctx.Sess.WriteString("(Registered users only)\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
handled = true
|
|
break
|
|
}
|
|
|
|
// J (Join) only makes sense for guests
|
|
if cmd.Key == 'J' && !ctx.Auth.IsGuest {
|
|
handled = true
|
|
break
|
|
}
|
|
|
|
// Run the command
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString(cmd.Label + "\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
if err := cmd.Handler(ctx); err != nil {
|
|
// Command signaled exit (goodbye, disconnect, etc.)
|
|
return
|
|
}
|
|
handled = true
|
|
break
|
|
}
|
|
|
|
if !handled {
|
|
// Unrecognized key — silently ignore, just like the original's
|
|
// default: command_accepted=FAILURE; continue;
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// errGoodbye is a sentinel used by cmdGoodbye to signal normal exit.
|
|
var errGoodbye = fmt.Errorf("goodbye")
|
|
|
|
// --- Command implementations ---
|
|
// Each replaces one of the original's *Com() functions.
|
|
|
|
// cmdHelp displays the command list.
|
|
// Replaces: case '?': MenuSend("MainMenu.Help")
|
|
func cmdHelp(ctx *Context) error {
|
|
// Try the screen file first — if the sysop has installed a custom
|
|
// help file, display it instead of the generated list.
|
|
helpPath := ctx.Cfg.System.Screens + "help.ans"
|
|
if _, err := os.Stat(helpPath); err == nil {
|
|
ctx.Sess.SendFile(helpPath)
|
|
return nil
|
|
}
|
|
|
|
// Fall back to generated help from the command table
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
|
|
ctx.Sess.WriteString("Available Commands\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
|
|
|
|
for _, cmd := range commands {
|
|
// Skip hidden commands the user can't access
|
|
if cmd.MinSec > 0 && ctx.User.SecStatus < cmd.MinSec {
|
|
continue
|
|
}
|
|
if !cmd.GuestOK && ctx.Auth.IsGuest {
|
|
continue
|
|
}
|
|
if cmd.Key == 'J' && !ctx.Auth.IsGuest {
|
|
continue
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.Printf(" [%c] ", cmd.Key)
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.Printf("%-12s", cmd.Label)
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf("%s\r\n", cmd.Description)
|
|
}
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
return nil
|
|
}
|
|
|
|
// cmdInfo displays system statistics.
|
|
// Replaces: ICom() → Report_Stat() from STATISTI.C
|
|
func cmdInfo(ctx *Context) error {
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
|
|
ctx.Sess.WriteString("System Information\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
|
|
|
|
ctx.Sess.Printf(" System: %s\r\n", ctx.Cfg.System.Name)
|
|
ctx.Sess.Printf(" Sysop: %s\r\n", ctx.Cfg.System.Sysop)
|
|
ctx.Sess.Printf(" Version: URIT BBS v0.2.0\r\n")
|
|
|
|
userCount, _ := ctx.Store.CountUsers()
|
|
boards, _ := ctx.Store.ListBoards()
|
|
libs, _ := ctx.Store.ListLibraries()
|
|
ctx.Sess.Printf(" Users: %d registered\r\n", userCount)
|
|
ctx.Sess.Printf(" Boards: %d\r\n", len(boards))
|
|
ctx.Sess.Printf(" Libraries: %d\r\n", len(libs))
|
|
|
|
ctx.Sess.NewLine()
|
|
return nil
|
|
}
|
|
|
|
// cmdStats displays system statistics.
|
|
// Replaces: Report_Stat() from STATISTI.C
|
|
//
|
|
// The original displayed very basic stats (system name, sysop, first
|
|
// online date). Ours shows activity counters and the recent call log,
|
|
// which is much more useful.
|
|
func cmdStats(ctx *Context) error {
|
|
stats, _ := ctx.Store.GetAllStats()
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
|
|
ctx.Sess.WriteString("System Statistics\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
|
|
|
|
ctx.Sess.Printf(" Total calls: %d\r\n", stats["total_calls"])
|
|
ctx.Sess.Printf(" Guest: %d\r\n", stats["guest_calls"])
|
|
ctx.Sess.Printf(" New: %d\r\n", stats["new_calls"])
|
|
ctx.Sess.Printf(" Validated: %d\r\n", stats["valid_calls"])
|
|
ctx.Sess.Printf(" New accounts: %d\r\n", stats["new_accounts"])
|
|
ctx.Sess.Printf(" Messages posted:%d\r\n", stats["messages_posted"])
|
|
ctx.Sess.Printf(" Mail sent: %d\r\n", stats["mail_sent"])
|
|
|
|
totalSecs := stats["total_time_secs"]
|
|
if totalSecs > 0 {
|
|
hours := totalSecs / 3600
|
|
mins := (totalSecs % 3600) / 60
|
|
ctx.Sess.Printf(" Total time: %dh %dm\r\n", hours, mins)
|
|
}
|
|
|
|
ctx.Sess.NewLine()
|
|
|
|
// Recent callers — show last 10
|
|
entries, _ := ctx.Store.ListCallLog(10)
|
|
if len(entries) > 0 {
|
|
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
|
|
ctx.Sess.WriteString("Recent Activity\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
|
|
|
|
for _, e := range entries {
|
|
ts := e.CreatedAt.Format("Jan 02 3:04PM")
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" %s ", ts)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
switch e.Event {
|
|
case "login":
|
|
ctx.Sess.Color(session.AnsiFgGreen)
|
|
ctx.Sess.Printf("%-12s", e.UserName)
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" logged in\r\n")
|
|
case "logoff":
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.Printf("%-12s", e.UserName)
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" %s\r\n", e.Detail)
|
|
default:
|
|
ctx.Sess.Printf("%-12s %s\r\n", e.UserName, e.Event)
|
|
}
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
ctx.Sess.NewLine()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// cmdTime displays time statistics.
|
|
// Replaces: TCom() from MENU.C
|
|
func cmdTime(ctx *Context) error {
|
|
ctx.Sess.CheckTime()
|
|
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Printf(" Connected: %s\r\n", ctx.Sess.ConnectedAt.Format("3:04 PM"))
|
|
ctx.Sess.Printf(" Current: %s\r\n", time.Now().Format("3:04 PM"))
|
|
remaining := ctx.Sess.TimeRemaining()
|
|
ctx.Sess.Printf(" Remaining: %d min %d sec\r\n",
|
|
int(remaining.Minutes()), int(remaining.Seconds())%60)
|
|
ctx.Sess.NewLine()
|
|
return nil
|
|
}
|
|
|
|
// cmdWho shows who is currently online.
|
|
// Not in the original (single-user system), but a natural multi-user feature.
|
|
func cmdWho(ctx *Context) error {
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
|
|
ctx.Sess.WriteString("Who's Online\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 50) + "\r\n")
|
|
|
|
if ctx.Nodes == nil {
|
|
ctx.Sess.WriteString(" (node list unavailable)\r\n")
|
|
} else {
|
|
nodes := ctx.Nodes.ActiveNodes()
|
|
if len(nodes) == 0 {
|
|
ctx.Sess.WriteString(" (no one online)\r\n")
|
|
} else {
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" %-6s %-20s %s\r\n", "Node", "User", "Connected")
|
|
ctx.Sess.WriteString(strings.Repeat("─", 50) + "\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
for _, n := range nodes {
|
|
name := n.UserName
|
|
if name == "" {
|
|
name = "(connecting)"
|
|
}
|
|
// Highlight the current user's own node
|
|
if n.Node == ctx.Sess.Node {
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
}
|
|
elapsed := time.Since(n.ConnectedAt).Truncate(time.Second)
|
|
ctx.Sess.Printf(" %-6d %-20s %s\r\n", n.Node, name, elapsed)
|
|
if n.Node == ctx.Sess.Node {
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
}
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" %d node(s) active\r\n", len(nodes))
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
}
|
|
ctx.Sess.NewLine()
|
|
return nil
|
|
}
|
|
|
|
// cmdAccount displays the user's own account info.
|
|
// Replaces: ACom() from ACOM.C
|
|
func cmdAccount(ctx *Context) error {
|
|
u := ctx.User
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
|
|
ctx.Sess.WriteString("Your Account\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
|
|
|
|
ctx.Sess.Printf(" Account: #%d\r\n", u.ID)
|
|
ctx.Sess.Printf(" Username: %s\r\n", u.Name)
|
|
ctx.Sess.Printf(" Status: %s (level %d)\r\n", u.StatusLabel(), u.SecStatus)
|
|
ctx.Sess.Printf(" Security: Board=%d Library=%d Bulletin=%d\r\n",
|
|
u.SecBoard, u.SecLibrary, u.SecBulletin)
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Printf(" Messages posted: %d\r\n", u.MessagesPosted)
|
|
ctx.Sess.Printf(" Mail sent: %d\r\n", u.MailSent)
|
|
ctx.Sess.Printf(" Mail received: %d\r\n", u.MailReceived)
|
|
ctx.Sess.Printf(" Uploads: %d\r\n", u.Uploads)
|
|
ctx.Sess.Printf(" Downloads: %d\r\n", u.Downloads)
|
|
if u.LastOn != nil {
|
|
ctx.Sess.Printf(" Last on: %s\r\n", u.LastOn.Format("Jan 02, 2006 3:04 PM"))
|
|
}
|
|
ctx.Sess.NewLine()
|
|
|
|
// Sub-menu for account actions (password change)
|
|
ctx.Sess.WriteString(" [C] Change password [Q] Return to main menu\r\n\r\n")
|
|
|
|
for {
|
|
ctx.Sess.WriteString(" Account> ")
|
|
ch, err := ctx.Sess.ReadKey(idleTimeout)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
switch toUpper(ch) {
|
|
case 'C':
|
|
ctx.Sess.WriteString("Change password\r\n")
|
|
if err := changePassword(ctx); err != nil {
|
|
return nil
|
|
}
|
|
return nil
|
|
case 'Q', '\x1b':
|
|
ctx.Sess.WriteString("Return\r\n")
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// changePassword lets a user change their own password.
|
|
// Not in the original (sysop edited passwords via Edit_Accounts).
|
|
func changePassword(ctx *Context) error {
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString(" Current password: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
current, err := ctx.Sess.ReadLineNoEcho(72, inputTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !auth.CheckPassword(ctx.User.PasswordHash, current) {
|
|
ctx.Sess.WriteString(" Incorrect password.\r\n")
|
|
return nil
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString(" New password: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
newPass, err := ctx.Sess.ReadLineNoEcho(72, inputTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(newPass) < 4 {
|
|
ctx.Sess.WriteString(" Password must be at least 4 characters.\r\n")
|
|
return nil
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString(" Confirm password: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
confirm, err := ctx.Sess.ReadLineNoEcho(72, inputTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if newPass != confirm {
|
|
ctx.Sess.WriteString(" Passwords do not match.\r\n")
|
|
return nil
|
|
}
|
|
|
|
hash, err := auth.HashPassword(newPass)
|
|
if err != nil {
|
|
ctx.Sess.WriteString(" Error hashing password.\r\n")
|
|
return nil
|
|
}
|
|
|
|
ctx.User.PasswordHash = hash
|
|
if err := ctx.Store.UpdateUser(ctx.User); err != nil {
|
|
ctx.Sess.WriteString(" Error saving password.\r\n")
|
|
log.Printf("[Node %d] Password save error: %v", ctx.Sess.Node, err)
|
|
return nil
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgGreen)
|
|
ctx.Sess.WriteString(" Password changed successfully.\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
return nil
|
|
}
|
|
|
|
// cmdMail is defined in mail.go — the full private mail subsystem.
|
|
|
|
// cmdMessages is defined in messages.go — the full message board subsystem.
|
|
|
|
// cmdBulletins is defined in bulletins.go — the full bulletin subsystem.
|
|
|
|
// cmdLibrary is defined in library.go — the full file library subsystem.
|
|
|
|
// cmdDownloadToken generates a web access token for HTTP file downloads.
|
|
// This is the bridge between the telnet session and the HTTP file server.
|
|
// The user gets a URL they can open in their browser to download files
|
|
// with their security level applied.
|
|
func cmdDownloadToken(ctx *Context) error {
|
|
if ctx.Tokens == nil {
|
|
ctx.Sess.WriteString(" Web downloads are not available.\r\n")
|
|
return nil
|
|
}
|
|
|
|
httpAddr := ctx.Tokens.HTTPAddress()
|
|
if httpAddr == "" {
|
|
ctx.Sess.WriteString(" HTTP server is not enabled.\r\n")
|
|
return nil
|
|
}
|
|
|
|
token, err := ctx.Tokens.GenerateWebToken(
|
|
ctx.User.ID, ctx.User.Name, ctx.User.SecLibrary)
|
|
if err != nil {
|
|
ctx.Sess.WriteString(" Error generating token.\r\n")
|
|
return nil
|
|
}
|
|
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
|
|
ctx.Sess.WriteString("Web Download Access\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
|
|
ctx.Sess.WriteString(" Open this URL in your browser:\r\n\r\n")
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.Printf(" http://%s/libraries?token=%s\r\n", httpAddr, token)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.WriteString(" Token valid for 1 hour.\r\n")
|
|
ctx.Sess.WriteString(" Your library access level will apply.\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.NewLine()
|
|
|
|
return nil
|
|
}
|
|
|
|
// cmdUsers displays user listings.
|
|
// Replaces: UCom() from UCOM.C
|
|
func cmdUsers(ctx *Context) error {
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
|
|
ctx.Sess.WriteString("User Listings\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
|
|
|
|
const pageSize = 20
|
|
offset := 0
|
|
|
|
for {
|
|
users, err := ctx.Store.ListUsers(offset, pageSize)
|
|
if err != nil {
|
|
ctx.Sess.WriteString(" Error loading users.\r\n")
|
|
return nil
|
|
}
|
|
if len(users) == 0 {
|
|
if offset == 0 {
|
|
ctx.Sess.WriteString(" No registered users.\r\n")
|
|
} else {
|
|
ctx.Sess.WriteString(" End of list.\r\n")
|
|
}
|
|
break
|
|
}
|
|
|
|
for _, u := range users {
|
|
lastOn := "never"
|
|
if u.LastOn != nil {
|
|
lastOn = u.LastOn.Format("Jan 02, 2006")
|
|
}
|
|
ctx.Sess.Printf(" [%3d] %-22s %-8s Last on %s\r\n",
|
|
u.ID, u.Name, u.StatusLabel(), lastOn)
|
|
}
|
|
|
|
if len(users) < pageSize {
|
|
break
|
|
}
|
|
|
|
ctx.Sess.WriteString("\r\n [M]ore [Q]uit ")
|
|
ch, err := ctx.Sess.ReadKey(idleTimeout)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if toUpper(ch) != 'M' {
|
|
ctx.Sess.WriteString("Quit\r\n")
|
|
break
|
|
}
|
|
ctx.Sess.WriteString("More\r\n")
|
|
offset += pageSize
|
|
}
|
|
|
|
ctx.Sess.NewLine()
|
|
return nil
|
|
}
|
|
|
|
// cmdFeedback sends a note to the sysop.
|
|
// Replaces: case 'F': Mail_Reply_To(System.Mail_List,1)
|
|
func cmdFeedback(ctx *Context) error {
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.WriteString("Send a note to the sysop.\r\n\r\n")
|
|
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("Subject: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
title, err := ctx.Sess.ReadLine("", 60, inputTimeout)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
title = strings.TrimSpace(title)
|
|
if title == "" {
|
|
ctx.Sess.WriteString("Cancelled.\r\n")
|
|
return nil
|
|
}
|
|
|
|
ctx.Sess.WriteString("Enter your message (blank line to finish):\r\n")
|
|
body, err := readMultiLine(ctx.Sess, 20)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if body == "" {
|
|
ctx.Sess.WriteString("Cancelled.\r\n")
|
|
return nil
|
|
}
|
|
|
|
// Find the sysop (user ID 1 by convention, same as the original)
|
|
sysop, err := ctx.Store.GetUser(1)
|
|
if err != nil || sysop == nil {
|
|
ctx.Sess.WriteString(" Error: sysop account not found.\r\n")
|
|
return nil
|
|
}
|
|
|
|
mail := &models.Mail{
|
|
Title: title,
|
|
Author: ctx.User.Name,
|
|
FromID: ctx.User.ID,
|
|
ToID: sysop.ID,
|
|
Recipient: sysop.Name,
|
|
Body: body,
|
|
}
|
|
|
|
if err := ctx.Store.CreateMail(mail); err != nil {
|
|
ctx.Sess.WriteString(" Error sending feedback.\r\n")
|
|
log.Printf("[Node %d] Feedback error: %v", ctx.Sess.Node, err)
|
|
return nil
|
|
}
|
|
|
|
ctx.User.MailSent++
|
|
ctx.Store.UpdateUser(ctx.User)
|
|
ctx.Store.IncrementStat("mail_sent", 1)
|
|
|
|
ctx.Sess.Color(session.AnsiFgGreen)
|
|
ctx.Sess.WriteString("Feedback sent to sysop.\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.NewLine()
|
|
return nil
|
|
}
|
|
|
|
// cmdJoin lets a guest create a permanent account.
|
|
// Replaces: JCom() from JCOM.C
|
|
func cmdJoin(ctx *Context) error {
|
|
if !ctx.Auth.IsGuest {
|
|
return nil
|
|
}
|
|
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Printf("Join %s as a permanent member.\r\n\r\n", ctx.Cfg.System.Name)
|
|
|
|
yes, err := ctx.Sess.Confirm("Become a permanent member? (Y/N) ", inputTimeout)
|
|
if err != nil || !yes {
|
|
ctx.Sess.WriteString("Not joining.\r\n")
|
|
return nil
|
|
}
|
|
|
|
// Show join screen
|
|
ctx.Sess.SendFile(ctx.Cfg.System.Screens + "join.ans")
|
|
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("Choose a username: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
name, err := ctx.Sess.ReadLine("", 30, inputTimeout)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if len(name) < 2 {
|
|
ctx.Sess.WriteString("Name too short. Cancelled.\r\n")
|
|
return nil
|
|
}
|
|
|
|
// Check availability
|
|
existing, _ := ctx.Store.GetUserByName(name)
|
|
if existing != nil {
|
|
ctx.Sess.WriteString("That name is already taken.\r\n")
|
|
return nil
|
|
}
|
|
|
|
// Check account limit
|
|
count, _ := ctx.Store.CountUsers()
|
|
if count >= ctx.Cfg.Users.MaxAccounts {
|
|
ctx.Sess.WriteString("Sorry, maximum accounts reached.\r\n")
|
|
return nil
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("Choose a password: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
pass, err := ctx.Sess.ReadLineNoEcho(72, inputTimeout)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if len(pass) < 4 {
|
|
ctx.Sess.WriteString("Password must be at least 4 characters.\r\n")
|
|
return nil
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("Confirm password: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
confirm, err := ctx.Sess.ReadLineNoEcho(72, inputTimeout)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if pass != confirm {
|
|
ctx.Sess.WriteString("Passwords do not match.\r\n")
|
|
return nil
|
|
}
|
|
|
|
// Optional comments to sysop (matches original's 3 comment lines)
|
|
ctx.Sess.WriteString("\r\nEnter a comment for the sysop (blank to skip):\r\n")
|
|
comments, _ := ctx.Sess.ReadLine("", 80, inputTimeout)
|
|
|
|
hash, err := auth.HashPassword(pass)
|
|
if err != nil {
|
|
ctx.Sess.WriteString("Error creating account.\r\n")
|
|
return nil
|
|
}
|
|
|
|
now := time.Now()
|
|
newUser := &models.User{
|
|
Name: name,
|
|
PasswordHash: hash,
|
|
Comments: strings.TrimSpace(comments),
|
|
Active: true,
|
|
SecStatus: ctx.Cfg.Users.NewSecurity.Status,
|
|
SecBoard: ctx.Cfg.Users.NewSecurity.Board,
|
|
SecLibrary: ctx.Cfg.Users.NewSecurity.Library,
|
|
SecBulletin: ctx.Cfg.Users.NewSecurity.Bulletin,
|
|
TimeLimit: int64(ctx.Cfg.Users.NewTimeLimit),
|
|
LastOn: &now,
|
|
}
|
|
|
|
if err := ctx.Store.CreateUser(newUser); err != nil {
|
|
ctx.Sess.WriteString("Error saving account.\r\n")
|
|
return nil
|
|
}
|
|
|
|
// Upgrade the session from guest to the new user
|
|
ctx.User = newUser
|
|
ctx.Auth.User = newUser
|
|
ctx.Auth.IsGuest = false
|
|
ctx.Auth.IsNew = true
|
|
ctx.Sess.TimeLimit = time.Duration(newUser.TimeLimit) * time.Second
|
|
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
|
|
ctx.Sess.Printf("Account created: %s (ID #%d)\r\n", newUser.Name, newUser.ID)
|
|
ctx.Sess.Color(session.AnsiFgYellow)
|
|
ctx.Sess.WriteString("Your account is NEW and must be validated by the sysop.\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
ctx.Sess.SendFile(ctx.Cfg.System.Screens + "joined.ans")
|
|
ctx.Sess.NewLine()
|
|
return nil
|
|
}
|
|
|
|
// cmdGoodbye logs the user off.
|
|
// Replaces: case 'G': return(STANDARD_LOGOFF)
|
|
func cmdGoodbye(ctx *Context) error {
|
|
ctx.Sess.NewLine()
|
|
|
|
// Save user data before logging off (like the original's Save_Account)
|
|
if !ctx.Auth.IsGuest && ctx.User.ID > 0 {
|
|
now := time.Now()
|
|
ctx.User.LastOn = &now
|
|
ctx.Store.UpdateUser(ctx.User)
|
|
}
|
|
|
|
ctx.Sess.SendFile(ctx.Cfg.System.Screens + "goodbye.ans")
|
|
|
|
ctx.Sess.Color(session.AnsiFgYellow)
|
|
ctx.Sess.Printf("Goodbye, %s! Thanks for calling %s.\r\n",
|
|
ctx.User.Name, ctx.Cfg.System.Name)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
ctx.Sess.Close(session.DisconnectNormal)
|
|
return errGoodbye
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
// readMultiLine reads multiple lines of input until a blank line.
|
|
// Replaces the original's multi-line comment input pattern.
|
|
func readMultiLine(sess *session.Session, maxLines int) (string, error) {
|
|
var lines []string
|
|
for i := 0; i < maxLines; i++ {
|
|
sess.Printf("%2d> ", i+1)
|
|
line, err := sess.ReadLine("", 78, inputTimeout)
|
|
if err != nil {
|
|
return strings.Join(lines, "\n"), err
|
|
}
|
|
if strings.TrimSpace(line) == "" {
|
|
break
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
return strings.Join(lines, "\n"), nil
|
|
}
|
|
|
|
// toUpper converts a byte to uppercase. Handles the &0x7f masking
|
|
// the original did on serial input to strip high bit.
|
|
func toUpper(ch byte) byte {
|
|
ch &= 0x7f
|
|
if ch >= 'a' && ch <= 'z' {
|
|
return ch - 32
|
|
}
|
|
return ch
|
|
}
|