// 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 }