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

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
}